Branching and Joining

A workflow is a single path until a <split> opens several, and a <join> closes them again. This page walks through every shape the two elements make — a choice, a fan-out, a race, a quorum, a rejection that stops everything — and what the engine does with each at run time. The attribute-by-attribute reference is in split and join.

                 ┌─ flow "mgr" ──► manager approval ──┐
start ──► split ─┤                                    ├──► join ──► publish
                 └─ flow "adm" ──► admin approval ────┘
          mode="all"                                wait="any"

The one rule to remember

A split says how branches start. A join says when they end. Nothing is inferred.

A split carries a mode, and always says it. A join carries for (which split it closes) and wait (what it waits for), and always says both. The engine never guesses which join belongs to which split, never reads a node’s role off how many arrows touch it, and never counts arrows into a join to decide when to continue. If two things are drawn, two things are written.

Pick the shape from what you want to happen:

I want to…​ Split Join

take one path or another

mode="one"

none — the paths simply meet again

do every one of several steps

mode="all"

wait="all"

do only the steps that apply

mode="some"

wait="all"

let the first of several finish and drop the rest

mode="all"

wait="any"

carry on once enough of them are done

mode="all"

wait="quorum" quorum="N"

stop the whole run if any branch says no

any of the above

plus <end terminate="true"/> in the branch

If you are coming from BPMN, one/some/all are the exclusive, inclusive and parallel gateways, and wait="any" is the event-based gateway’s race. The mapping is spelled out in Coming from BPMN, and every split-mode × policy combination in Every combination, side by side.

Choosing one path: mode="one"

The everyday branch: route on the record, take exactly one way out.

<split id="route" mode="one">
  <flow id="big"     when="self.totalAmount &gt; 10000" next="approve"  title="Large order"/>
  <flow id="blocked" when="self.customer.blocked"       next="reject"   title="Blocked customer"/>
  <flow id="rest"    default="true"                      next="confirm"/>
</split>

<task id="approve" role="sales.manager" next="confirm"/>
<script id="reject" next="done"><![CDATA[ self.status = 'REJECTED'; save(); ]]></script>
<script id="confirm" next="done"><![CDATA[ self.status = 'CONFIRMED'; save(); ]]></script>
<end id="done"/>

What happens:

  1. The flows are tested in the order written. big is tested first; if it holds, blocked is never evaluated. Order is precedence, and you chose it.

  2. The first flow whose when holds is taken. If none holds, the default flow is taken.

  3. The path that was taken runs on. approve and reject both lead to confirm — the alternatives meet again at an ordinary node. There is no join, because there is nothing to wait for: only one of them ever ran.

A one split never has a <join>. Writing one for it is refused (join-for-invalid), because a barrier that only one branch can ever reach would wait for nothing. Where the alternatives come back together, point them at the same node.

What if nothing matches?

Without a default, a one split whose conditions all come out false fails the run at the split. The symptom is easy to misread: the run is recorded as failed, the trigger fires again on the record’s next update, and every node before the split runs a second time — so what you notice is duplicated work upstream, not a split that matched nothing.

The failure message names both fixes, and they mean different things:

  • add <flow default="true" …​/> — "if none of these apply, carry on this way";

  • put a <wait on="condition"> before the split — "the thing I am testing has not happened yet". A split decides on what the record says now; it never waits.

Give a default flow to every one split whose conditions do not provably cover every case. The designer seeds no default for you.

Doing every step: mode="all" + wait="all"

Several things must all happen before the run may continue.

<split id="checks" mode="all" title="Run every check">
  <flow id="credit" next="creditCheck"/>
  <flow id="stock"  next="stockCheck"/>
  <flow id="trail"  next="auditTrail"/>
</split>

<script id="creditCheck" next="merge"><![CDATA[ vars.creditOk = self.customer.rating >= 3; ]]></script>
<script id="stockCheck"  next="merge"><![CDATA[ vars.stockOk  = self.items.every(i => i.available); ]]></script>
<script id="auditTrail"  next="merge"><![CDATA[ track(_t('Checks run')); ]]></script>

<join id="merge" for="checks" wait="all" next="persist" title="All checks done"/>
<script id="persist" next="done"><![CDATA[ save(); ]]></script>
<end id="done"/>

What happens:

  1. Every flow is taken — an all split evaluates no conditions, and its flows may carry neither when nor default.

  2. The branches run one after another, in the order written, against the same record and the same vars. "All" is about which paths are taken, not about running them at the same time. (Camunda’s default is the same; only its async continuations differ.)

  3. Each branch ends at merge. When the last one arrives, merge runs once and continues to persist.

Save once, after the join

Every branch of one run shares a single in-memory record. Let the branches accumulate their changes and persist them once past the join, as persist does above. Saving inside each branch writes the record once per branch: every write runs the record’s save hooks, may re-trigger a workflow watching a field that branch just set, and exposes half-updated states to anything reading the record in between. One save past the join goes from the old state to the final one.

This applies to some and all. A one split runs exactly one branch, so the save() in the node that acts is already a single write.

A branch may wait

Any branch may hold a <task> or a <wait>. When one branch parks, the engine writes down where every branch of that split got to, and the join counts them back in as each finishes. The run shows waiting until the last branch it is owed reports back; the join then runs exactly once. Completing one approval never carries the run past a barrier its siblings have not reached.

<split id="both" mode="all" title="Both approvals">
  <flow id="mgr" next="manager"/>
  <flow id="fin" next="finance"/>
</split>
<task id="manager" role="sales.manager" next="merge"/>
<task id="finance" role="finance.team"  next="merge"/>
<join id="merge" for="both" wait="all" next="confirm"/>

Two approvals sit in two inboxes at once. Whoever answers second releases the join. A branch may park more than once before its join (two approvals in a row is an ordinary shape), and a branch waiting on a timer keeps its own wake time, so branches with different delays wake independently.

A branch may also wait on a <call> that calls once and parks: the call is what that branch is holding, and the child finishing is what brings it back. See call.

Doing what applies: mode="some" + wait="all"

Like all, but each flow has a condition and only the matching ones are taken.

<split id="route" mode="some" title="Whatever applies">
  <flow id="large" when="self.totalAmount &gt;= 1000" next="flagApproval" title="Large order"/>
  <flow id="email" when="self.customer.email"          next="noteEmail"    title="Has email"/>
  <flow id="none"  default="true"                       next="merge"        title="Nothing special"/>
</split>

<script id="flagApproval" next="merge"><![CDATA[ self.needsApproval = true; ]]></script>
<script id="noteEmail"    next="merge"><![CDATA[ track(_t('Customer will be emailed')); ]]></script>

<join id="merge" for="route" wait="all" next="persist"/>

What happens:

  1. Every condition is evaluated (unlike one, which stops at the first hit). Every flow whose when holds is taken — one, two, or all of them.

  2. If none holds, the default flow is taken instead. Here it points straight at the join, which is allowed: a flow may lead directly to its join, and it counts as one branch that arrived.

  3. The join waits for the branches that were actually taken, and only those. It does not care how many flows the split has on paper.

That last point is what makes some safe to use. In BPMN the inclusive join has to work out which tokens might still be on their way; here the split knows what it started, and the join asks the split. A some split that takes one branch releases its join after one arrival. No look-ahead, no guessing.

Letting the first one win: wait="any"

Several branches start; the first to finish carries the run on, and the others are cancelled. This is "either approval will do", or "an answer or a deadline".

<split id="either" mode="all" title="Ask both">
  <flow id="mgr" next="manager"/>
  <flow id="adm" next="administrator"/>
</split>

<task id="manager"       user="alice" next="whoever" title="Manager approval"/>
<task id="administrator" user="bob"   next="whoever" title="Admin approval"/>

<join id="whoever" for="either" wait="any" next="note" title="Whoever answers first"/>
<script id="note" next="done"><![CDATA[ track(_t('Approved by') + ' ' + task.completedBy.name); ]]></script>
<end id="done"/>

What happens:

  1. Both approvals are created; Alice and Bob each see one.

  2. Alice approves. Her branch arrives at whoever; the join is satisfied.

  3. Bob’s branch is cancelled: his task is withdrawn and disappears from his inbox rather than sitting there for work that is over. Had his branch been waiting on a timer, the timer would be disarmed.

  4. The run continues to note — in the same run, with the same vars, so task still describes the approval that won.

A race is not only for approvals. Put a <wait on="timer"> on one flow and an approval on another, and "approve within seven days or approve automatically" is the same shape:

<split id="race" mode="all">
  <flow id="mgr"  next="manager"/>
  <flow id="adm"  next="administrator"/>
  <flow id="auto" next="sevenDays"/>
</split>
<task id="manager"       user="alice"  next="decided"/>
<task id="administrator" user="bob"    next="decided"/>
<wait     id="sevenDays"     on="timer" delay="7" unit="day" next="markAuto"/>
<script   id="markAuto"      next="decided"><![CDATA[ self.approvedBy = 'auto'; ]]></script>
<join id="decided" for="race" wait="any" next="confirm"/>

Whichever of the three finishes first releases decided; the other two are cancelled, and confirm runs once.

For the single commonest case — one approval with a deadline — a boundary on the task is simpler than a race and stays the recommendation. Reach for wait="any" when there are genuinely several ways to finish.

When a branch wins immediately

Branches run in order, so a branch that finishes without waiting can win before the ones after it even start. In the example above, if manager were a <script> rather than an approval, it would arrive at decided in the same pass, the join would be satisfied on the spot, and administrator and sevenDays would never run. Nothing of theirs is ever created — not a task, not a timer.

That is correct behaviour for a race, and it is worth knowing when you write one: put the branch that can finish instantly last if the others should at least be started.

What a race may not contain

A <boundary cancel="false"> inside a race is refused (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" (the default), move the boundary outside the race, or make the join wait="all".

Carrying on once enough are done: wait="quorum"

The same shape as any, with a threshold: "any two of the three reviewers".

<split id="board" mode="all" title="Ask three reviewers">
  <flow id="r1" next="reviewOne"/>
  <flow id="r2" next="reviewTwo"/>
  <flow id="r3" next="reviewThree"/>
</split>
<task id="reviewOne"   user="alice" next="enough"/>
<task id="reviewTwo"   user="bob"   next="enough"/>
<task id="reviewThree" user="carol" next="enough"/>

<join id="enough" for="board" wait="quorum" quorum="2" next="approved" title="Any two of three"/>

The second reviewer to answer releases the join; the third’s task is withdrawn. quorum="1" is allowed and means what any means.

Use a quorum only when the branches are interchangeable. If particular approvals are required — the manager and finance — that is wait="all" over exactly those flows, not a count.

Only arrivals count

A quorum counts branches that arrive at the join. A branch that reaches a plain <end> before the join is neither an arrival nor a rejection: it is simply one fewer branch that could still arrive.

So, if a reviewer’s rejection should not count towards the quorum, route it to an <end>:

<task id="reviewOne" user="alice" next="oneSays"/>
<split id="oneSays" mode="one">
  <flow id="no"  when="task.rejected" next="oneDeclined"/>
  <flow id="yes" default="true"       next="enough"/>
</split>
<end id="oneDeclined"/>

Now only approvals reach enough. Two rejections out of three make the quorum impossible — and the engine says so rather than waiting for ever.

A quorum that can never be met

Two rules keep a race from waiting for something that cannot come:

  • A quorum above the split’s number of flows is refused when the workflow is saved (join-quorum-invalid): two flows can never produce three arrivals.

  • At run time, the moment the branches still able to arrive are too few to reach the threshold — because some ended early, or a some split selected fewer to begin with — the run fails, with a message naming the join, the threshold and the counts. The alternative would be a record stuck waiting with nothing left that could ever wake it.

If "not enough approvals" is a normal business outcome rather than an error, do not model it as a quorum: use wait="all" and route on what the branches wrote to the record afterwards.

Every combination, side by side

The sections above show each policy with the split it is most often paired with. A join’s behaviour depends on both — what the split selected and what the join waits for — so here is the whole grid.

Split wait="all" wait="any" wait="quorum" quorum="N"

mode="one"

No join. One branch runs; the alternatives meet at an ordinary node. A join naming a one split is refused (join-for-invalid).

mode="some"

Waits for the branches whose conditions held, and only those. One match means one arrival releases it. If nothing matched, the default flow is the only branch: leading to the join, it is one arrival; leading to its own <end>, the join sees nothing and is skipped.

The first of the selected branches to arrive wins; the other selected ones are cancelled. Flows whose conditions were false were never started and are untouched. One match releases immediately.

Checked before any branch runs: if fewer than N flows were selected, the run fails at the split with the counts, having done nothing. Otherwise as for all.

mode="all"

Waits for every flow. Continues once when the last one arrives or ends.

The first flow to arrive wins; every other flow is cancelled, including those not yet started.

The N-th arrival wins; the rest are cancelled. N above the flow count is refused at save (join-quorum-invalid).

What each policy does with the events a branch can produce:

Event all any / quorum

A branch arrives at the join

Counted. The join continues when nothing is left that could still arrive.

Counted. The join continues the moment the count reaches N (1 for any); everything still running is cancelled.

A branch reaches a plain <end> before the join

Not an arrival, not a problem: one fewer branch to wait for.

Not an arrival, and it counts against you: one fewer branch that could still reach N.

Every branch ended before the join

The join is skipped. The node after it never runs, and the run ends as completed — it ran out of graph. Nothing failed.

The run fails: the barrier could never release. The message names the join, the threshold and the counts.

Too few branches can still arrive to reach N

Not applicable.

The run fails at once, without waiting for the branches that are left.

A branch fails (an error, or fail())

The run fails, whatever the policy. any means first arrival, not first success: a failing branch is not skipped in favour of the others. Its siblings' approvals are withdrawn.

A branch reaches <end terminate="true"/>

The run ends there, as terminated. The other branches are cancelled and their approvals withdrawn; the join never runs.

A branch inside a nested split ends before its inner join

Counted by the inner join under its own policy. If the inner join is skipped (an all join with no arrivals), the outer branch it sat in counts as ended early, not as arrived.

Two of those rows surprise people, in opposite directions:

Under all, "everyone said no" is not a failure. Route each rejection to a plain <end> and the join simply never runs — the run finishes, completed, with the node after the join untouched. If a rejection should stop the process, send it to an <end terminate="true"/>; if it should fail the run, call fail() in a script. The engine does not decide for you which of the three "no" means.

<split id="both" mode="all">
  <flow id="mgr" next="manager"/>
  <flow id="fin" next="finance"/>
</split>
<task id="manager" role="sales.manager" next="managerSays"/>
<split id="managerSays" mode="one">
  <flow id="no"  when="task.rejected" next="managerDeclined"/>   <!-- plain end: this branch is simply over -->
  <flow id="yes" default="true"       next="merge"/>
</split>
<end id="managerDeclined"/>
<task id="finance" role="finance.team" next="merge"/>
<join id="merge" for="both" wait="all" next="confirm"/>

Here confirm runs after finance approves, whether or not the manager did — one arrival is enough once nothing else can come. Only if both ended early would confirm be skipped.

Under any or quorum, an early end is a lost vote. The same rejection routed to a plain <end> reduces what can still arrive; when the remaining branches can no longer reach the threshold the run fails rather than waits. That is the intended reading of a race — "one of these must finish" — and it is why the section on quorums says to route rejections to an <end> deliberately, knowing that two of three rejections fail the run.

Stopping everything: a terminate end inside a branch

"A rejection from either side stops the whole thing" is not a join policy — it is an <end terminate="true"/> reached from inside a branch.

<split id="both" mode="all" title="Both approvals">
  <flow id="mgr" next="manager"/>
  <flow id="fin" next="finance"/>
</split>

<task id="manager" role="sales.manager" next="managerSays"/>
<split id="managerSays" mode="one">
  <flow id="no"  when="task.rejected" next="stop"  title="Rejected"/>
  <flow id="yes" default="true"       next="merge" title="Approved"/>
</split>

<task id="finance" role="finance.team" next="financeSays"/>
<split id="financeSays" mode="one">
  <flow id="no"  when="task.rejected" next="stop"  title="Rejected"/>
  <flow id="yes" default="true"       next="merge" title="Approved"/>
</split>

<end id="stop" terminate="true" title="Stopped"/>
<join id="merge" for="both" wait="all" next="confirm" title="Both approved"/>

Reaching stop from either branch ends the run at once: the other approval is withdrawn, and merge never runs. Both branches may share the same terminate end — an end does no work, so there is nothing to run twice.

The run is recorded with the status terminated rather than completed, so it can be told apart from one that ran out of graph.

Splits inside splits

A branch may contain a split of its own. Each split has its own join, and the inner join simply carries its branch on towards the outer one:

                       ┌─ X ─┐
         ┌─ A ─► inner ┤     ├─► innerJoin ─┐
outer ───┤             └─ Y ─┘              ├─► outerJoin ─► ...
         └─ B ──────────────────────────────┘
<split id="outer" mode="all">
  <flow id="a" next="inner"/>
  <flow id="b" next="stepB"/>
</split>
<split id="inner" mode="all">
  <flow id="x" next="stepX"/>
  <flow id="y" next="stepY"/>
</split>
<!-- stepX, stepY lead to innerJoin; stepB leads to outerJoin -->
<join id="innerJoin" for="inner" wait="all" next="outerJoin"/>
<join id="outerJoin" for="outer" wait="all" next="done"/>

outerJoin waits for two things: branch b, and branch a — which arrives when innerJoin has released. A branch inside the inner split may park, and waking it resumes exactly that branch.

Two nested splits may not share one join (join-for-invalid). A join closes one fork; if the inner branches took the outer barrier, the outer fork would never see them arrive. Give each split its own join.

Two alternative splits — the two flows of a one split each leading to their own all split — may not share a join either: each fork names its join, and a join names one split.

What the engine does at run time

Knowing the mechanics explains most of the rules above.

Branches run in order

The branches of one split are walked one after another, in the order the flows are written, within the same pass. They share the record and the same vars. Nothing runs in parallel on separate threads.

Parking writes the split down

If no branch parks, the whole split — fork, branches, join — completes in one pass and leaves no trace beyond the run itself. The moment one branch parks (an approval, a timer), the engine records that activation of the split: which flows were taken, where each branch got to, and which join they are owed to. Waking one branch resumes that branch alone, and the join counts arrivals against the branches that were actually taken.

The join runs once

Whether all branches arrive in one pass or over three weeks, the node after the join runs exactly once per activation. If the same split is reached again later (a loop, or a later trigger), that is a new activation with its own count.

Cancelling a branch cancels what it holds

When a race is decided, or a branch fails, or a terminate end is reached, the losing branches' approvals are withdrawn from their inboxes and their timers disarmed. Deleting the record does the same for every branch of every open split on it.

Failure is not partial

A branch that fails fails the run, and its siblings' approvals are withdrawn — there is no "keep the branches that were fine". Model an expected business outcome as a flow condition, not as an error.

In the run viewer (Administration → Workflow Management → Workflow Runs), a branch a race cancelled is ringed amber, a failed one red, and the panel under the graph names each state in words. See Monitoring runs.

Rules the validator enforces

Everything below is caught when the workflow is saved or loaded, with a message naming the nodes involved. Two failures are only knowable at run time and are listed at the end.

On the split

Code Meaning and fix

split-mode-required

Every split says one, some or all. There is no default.

split-flow-required

A split with no flows leads nowhere. Add at least one.

flow-id-required / flow-id-duplicate

Every flow has an id, unique within its split. It is the branch’s identity — two flows may legally point at the same node, so next cannot be.

flow-next-required

A flow has no next.

flow-when-required

On a one or some split, every flow that is not the default needs a when.

flow-when-unexpected

A flow of an all split may carry neither when nor default: every flow is taken, so there is nothing to decide.

flow-when-unexpected

The default flow is the one taken when nothing matched; a condition on it would never be read. Drop one or the other.

flow-default-duplicate

At most one default per split.

On the join

Code Meaning and fix

join-for-required

A join says which split it is for.

join-for-unresolved

for names something that is not a split.

join-for-invalid

for names a one split. Its alternatives converge at an ordinary node; delete the join and point the branches at the next step.

split-join-required

A some or all split has no join naming it. Add one — every such split has exactly one.

split-join-duplicate

Two joins name the same split. One split, one join.

join-wait-required

A join says all, any or quorum.

join-quorum-required

wait="quorum" needs quorum="N".

join-quorum-unexpected

quorum on an all or any join would read as a threshold being honoured. Remove it, or switch to wait="quorum".

join-quorum-invalid

quorum is at least 1.

join-quorum-invalid

quorum is more than the split has flows. Lower it, or add flows.

On the shape of the graph

These are the rules that keep a barrier meaningful. Each one describes a graph that would silently wait for the wrong thing, or not wait at all.

Code Meaning and fix

flow-next-invalid

Two branches of the same split reach the same node before their join. Concurrent branches may not share work — the node would run once per branch, or once when you meant twice. Give the shared work its own <call>, or move it after the join. Sharing a plain <end> is fine; an end does no work.

node-next-invalid

A node outside the split points into one of its branches. A path that joins a branch part way down reaches the join without the barrier the split opened, and would pass through it without waiting. Enter through the split itself, or point at a node after the join.

node-next-invalid

A node outside the split points directly at its join. Same problem, same fix.

join-for-invalid

An inner split and the outer split containing it name the same join. Give the inner split its own.

boundary-cancel-invalid

A cancel="false" boundary sits inside an any/quorum race. Its escape branch could not be cancelled with the losing branch. Use cancel="true", move it out, or use wait="all".

Only knowable at run time

Failure Meaning and fix

no flow matched

A one or some split with no default matched nothing. The run fails at the split; see What if nothing matches?.

quorum lost

Too few branches can still arrive to reach quorum — a some split selected fewer than the threshold, or branches ended early. The run fails with the counts. Route early ends past the join, or lower the wait.

In the designer

The Studio workflow designer draws the same two elements. Split and Join are two palette entries, each with its own required properties.

To branch a workflow:

  1. Drag Split from the palette onto the canvas and connect the previous node to it.

  2. In the inspector, set Mode: the first match (✕), every match (○) or every branch (+). Set it before drawing branches — switching to every branch later clears any conditions you had written.

  3. Draw an edge from the split to each branch’s first node. Each edge is a flow; the designer gives it a stable id.

  4. Select an arrow to edit its Condition and Title, or right-click it to Make default (or Make conditional again) and to Move earlier or Move later — the order is the order the flows are tested in. The label on the arrow is the title, or the condition when there is no title.

To close the branches (for every match and every branch):

  1. Open the split’s context menu and choose Join, or drag Join from the palette. From the menu, Waits for split is filled in for you.

  2. Connect every branch’s last node to the join.

  3. In the inspector, set Wait: every branch, the first arrival, or enough arrivals — which reveals an Arrivals field for the count.

  4. Connect the join to whatever follows.

Reading the canvas: a split’s badge is its mode (✕ ○ ); a join's badge is its policy — `` for one that waits for every branch, the count for one that continues on the first arrival or the first N. So the two halves of a race are legible without opening either inspector.

Save validates the definition with the same rules listed above; problems are reported by node. There are no designer-side rules that could drift from the engine.

Coming from BPMN

BPMN Here Note

Exclusive gateway ✕ (split)

<split mode="one">

identical: first matching flow, else default

Exclusive gateway ✕ (merge)

nothing

alternatives point at the same node

Inclusive gateway ○ (split + join)

<split mode="some"> + <join wait="all">

the join asks the split what it started; no look-ahead

Parallel gateway + (split + join)

<split mode="all"> + <join wait="all">

Event-based gateway

<split mode="all"> + <join wait="any">

over approvals, timers and conditions; there is no message model to race

Sub-process with a terminate end, used as a race

<join wait="any">

continues the same run afterwards; no sub-process needed

Terminate end event

<end terminate="true"/>

inside a branch: stops the run, withdraws the other branches' approvals

AND-join after an XOR-split (the classic deadlock)

cannot be written

a join names its split, and a one split cannot have one (join-for-invalid)

Camunda’s join is local: a parallel join waits for one token per incoming sequence flow, and does not know which gateway produced them. That is why an AND-join downstream of an XOR-split hangs there. Here the join asks the split, so that diagram is a validation error naming both nodes, not a stuck record. On every diagram that works in Camunda the behaviour is the same; on the ones that do not, this engine is louder.

See Migrating from the BPM module for the full element map.

Common mistakes

Expecting all to run branches at the same time

It does not. Branches run in order, sharing the record. Put the branch that must see the others' results last, or read them after the join.

Saving in every branch

Each save is a write with hooks and triggers of its own. Save once past the join.

Sharing a script between two branches

Refused as flow-next-invalid. What looks like reuse would run the script once per branch. Put it after the join, or in a <call> each branch calls.

Giving a one split a join

Refused as join-for-invalid. Point the alternatives at the same next node instead.

Forgetting the default flow

A one/some split that matches nothing fails the run, and the upstream nodes run again on the next trigger. Add a default.

Reading a quorum as "N approvals"

A quorum counts arrivals. If a rejection must not count, route it to an <end> before the join.

Expecting all to fail when every branch rejects

It does not. Early ends are not failures; with no arrivals the join is skipped and the run ends as completed. Use a terminate end to stop, or fail() to fail — see Every combination, side by side.

Expecting any to survive a failing branch

It does not. A race is decided by the first arrival; a branch that throws fails the run like anywhere else.

Racing a non-cancelling boundary

Refused as boundary-cancel-invalid. Its escape branch could not be cancelled with the branch that lost.

Pointing a later node back into a branch

Refused as node-next-invalid. A loop around a whole split-and-join block is fine; a loop into the middle of a branch is not, because it would reach the join without a barrier.