Split & Merge — Nodes That Carry Policies
Fan-out and join already work, and are invisible: N plain out-edges IS a fan,
in-degree > 1 IS a wait-all join. split and merge add no
topology — they are the cards a POLICY hangs on, and they ship with their first two: run the
arms one at a time, and release on the first arm home.
1 · What already ships, and why nobody can see it
Four rules, all live, all load-bearing for everything below.
| Rule | Today | Where |
|---|---|---|
| fan-out | Edge COUNT, not a node kind. A node with N unlabelled out-edges runs all N once it completes. | NEP-0006 §Fan-out (v1.6) |
| join | Implicit. ANY node with >1 live inbound edge wait-alls. No node, no config — readiness is in-degree. | decider.ts:201-209 |
| skip-propagation | An untaken arm resolves to skip immediately and propagates, so a join waits only on LIVE arms. Deadlock-free by construction, not by an author declaring skippability. | decider.ts:113-132 |
| sibling failure | A fatal arm emits FailWorkflow at once and does NOT cancel a dispatched sibling. Nothing here cancels a claimed activity. | decider.ts:346 |
The legibility problem is concrete, not aesthetic: every unlabelled out-edge leaves the
same single port dot — one Handle for all of them
(GovernedCanvas.tsx:193). A three-way fan and a one-step chain draw identically, and
a join is readable only by counting arrowheads.
2 · Nodes, not edge annotations
The same semantics could be a field on the fanning node
(fanout: {mode}) and a field on the joining node (join: {mode}) —
zero new kinds, zero node-cap cost. Rejected on three grounds:
- An unseen policy is ungoverned. PD003's thesis is that a governed workflow is legible on the canvas. "These arms run one at a time" buried in a config panel is a rule reviewers never read.public/designs/003-governed-workflows/
- Ordering needs ports. Sequential order is per-arm, and an arm's home on a card is a port. The card-growth machinery already exists for exactly this.canvas/metrics/port-geometry.ts:64
- The mode flip must not be a rewire. With the node present,
parallel→sequentialis one config change; with annotations, adding a policy later means editing the node upstream of the shape it governs.
The cost is honest: merge · wait_all becomes a second way to spell the implicit
join. That is deliberate, not an oversight — implicit stays the default and stays legal, and the
explicit node exists so that arriving at a non-default policy needs no rewire.
The compile-down, explicitly
Two of the four modes ARE the shipped behaviour wearing a card, so half this surface carries no decider risk at all.
| Node · mode | Compiles down to | Decider |
|---|---|---|
split · parallel | Its N unlabelled out-edges — today's fan-out, byte for byte | none — settles immediately, schedules nothing (the branch/switch decider-only pattern) |
merge · wait_all | in-degree > 1 wait-all — today's implicit join | none — same pass-through |
split · sequential | Arm k is taken only once every arm < k has settled | new readiness rule |
merge · wait_any | First settled live arm releases; the losers are pruned or left running, per on_loss (§6) | new readiness + a winner |
3 · split
config: { mode: "parallel" | "sequential" }. The mode words are
loop_over_items's own z.enum(["sequential", "parallel"])
(graph.ts:152) — not "serial". A second
vocabulary for one concept is the drift worth refusing up front.
What sequential reuses — and what it does not
The claim that sequential rides loop_over_items' machinery is half true,
and the half that is false matters:
| loop_over_items · sequential | split · sequential | |
|---|---|---|
| what iterates | Items of one array, against ONE target workflow | Distinct arms of THIS graph |
| each unit is | A child RUN via decideChildRun | An ordinary node with its own topo seq |
| seq allocation | Item-indexed disjoint strides (20_000 + seq*64 + i) | Nothing new — arms already have seqs |
| shared | The short-circuit scan: stop at the first unsettled unit and schedule nothing past it (loop.ts:170-173). That pattern is what split borrows; the machinery is not reusable. | |
What "one at a time" means: the region question
If arm A is a three-node chain, does arm B start when A's first node finishes, or when A is done? Only the second is what an author means, so an arm is a REGION, not a node.
flowchart LR
S{{"split · sequential
order: a1, b1"}}
S -->|1| a1[a1 draft]
a1 --> a2[a2 send]
S -->|2| b1[b1 draft]
b1 --> b2[b2 send]
a2 --> M(("merge"))
b2 --> M
subgraph RA["region 1 — must fully settle first"]
a1
a2
end
subgraph RB["region 2 — starts after region 1"]
b1
b2
end
Arm k's region = the nodes reachable from arm k's head that arm k's head dominates — every path from the trigger to them passes through that head. A node reachable from two arms is by definition below both, so it belongs to neither region; it is the merge's business. This is a pure, publish-time structural property of an immutable graph, so the decider stays a pure replay function.
Order lives in config.order — a list of the split's immediate successor node
ids. It is deliberately NOT edge labels: labels are the exactly-one-of-N arm mechanism, and
NEP-0006 rule 4b refuses any label no kind claims. Ordinal labels would need "all arms taken,
one at a time", which is the opposite of what a label means everywhere else.
No failure policy — and why that is the answer
Both halves of a stop/continue knob are already spoken for:
| Want | Already expressible |
|---|---|
| stop on first arm failure | Inherited, free. A fatal arm emits FailWorkflow and nothing further schedules (decider.ts:346) — sequential's default IS stop-on-first-failure. |
| continue past a failed arm | An error edge on that arm's node. The arm settles as failed-but-handled, the scan advances. Shipped since #462. |
So v1 adds no failure config. Consistency with parallel holds vacuously: parallel does not cancel a dispatched sibling because siblings are in flight; sequential has none in flight.
4 · merge
config: { mode: "wait_all" | "wait_any", on_loss?: "prune" | "continue" } — the
second field is wait_any-only and defaults to prune (§6).
wait_all restates the shipped rule: not ready while any inbound edge is
unresolved; skip if none resolved taken; otherwise run (decider.ts:201-209).
wait_any: the first live arm to settle releases the merge. Two
precisions the phrase hides:
- A failed arm cannot win. A failed source's plain out-edge resolves to skip
(
decider.ts:130), so failure never counts as arriving. If every arm skips, the merge skips — exactly like today. - A merge fed only by arms of ONE exclusive source (a
switch's arms, an approval's outcomes) has exactly one live arm ever. There is no race; see §6.
Replay determinism needs no new event
The winner is the live arm with the earliest settle index in history, ties broken by
topo seq. That is already durable and already deterministic: history is append-only, so a later
poll sees a strict superset with no reordering — once an arm is earliest, it is earliest on every
replay forever. No RecordSideEffect, no new column.
Settle index for a node with no event of its own (a branch, a
switch) = the max over its live inbound sources' settle indices. Total, pure,
defined for every node.
One deliberate departure from NEP-0006 v1.6's "no merged-output shape": a
wait_any merge's own output is { won: "<armNodeId>" }. It is the
first node whose output describes its INBOUND edges — needed because "which arm won" is the one
fact a reader, a downstream reference, and the run canvas all want, and deriving it in three
places is how mirrors drift.
5 · The losing arms — the analysis behind the default
Decided (§6): both semantics ship, selectable per merge. This section is left as written, because it is now the argument for which one is the DEFAULT — the reasoning has to stay visible for the default to be reviewable.
Framed as (a) cancel via the stop cascade, (b) run to completion and discard the results.
Checking (a) against the code changes the question: the stop cascade
(run-stop.ts) stops descendant RUNS. A losing arm is not a run — it is activities
inside this one, and nothing in this system cancels a claimed activity
(builder-surface.ts:187-189). So (a) as written is only partly buildable, and the
real third option is the one to pick.
- Stop scheduling. Every unstarted node in a losing region resolves to skip. Pure decider, zero new spend — this is the whole budget win.decider.ts:206-209
- Stop the losers' child runs. A losing arm's live
sub_workflow/loop_over_itemschildren ARE runs, so the shipped cascade reaches them — invoked from an activity, never the pure decider.run-stop.ts:70-107 - Accept the in-flight tail. An already-claimed activity finishes uncancellably. Its result records and is never read — precisely today's fatal-sibling rule.NEP-0006 §Fan-out
flowchart LR
S{{"split"}} -->|1| F["fast arm"]
S -->|2| L["slow arm
claimed — finishes, unread"]
F -->|"first home · wins"| M(("merge
wait_any"))
L -.-> L2["l2 send email
never scheduled"]
L2 -.->|"pruned · skip"| M
M --> D["downstream"]
Recommendation, now the default: prune. The reason is governance, not cost.
Option (b) discards results, but a losing arm keeps acting — a
capability node sends the email, files the ticket, moves the money, and its output
is dropped afterwards. "Results discarded" and "effects discarded" are not the same sentence, and
a governed-workflow product cannot ship the first while implying the second. The budget argument
is real too and points the same way — orphaned losers burning descendant runs contradicts the
200-run descendant cap (run-start.ts:33) — but it is the second reason, not the
first.
| Ripple | prune — the default | continue — opt in |
|---|---|---|
| replay determinism | Winner = earliest settle index, monotone-stable; losers stay losers even after their completions land later in history. | Same winner rule needed anyway — (b) does not avoid this work. |
| budget | Unstarted nodes cost nothing; losing child runs stopped; only the claimed tail is paid for. | Full cost of every loser, plus their whole descendant subtrees. |
| skip-propagation | Pruned = skip, never notReady. See the invariant below. | Untouched — losers settle normally. |
| side effects | Bounded to steps already claimed at the moment of the win. | Unbounded — every action on every losing arm still happens. |
| run canvas | Needs a per-node pruned state (§8) — the losers must stop spinning. | Losers render done, which reads as "this ran and mattered" — §6 fixes that with a distinct unread mark. |
The deadlock-freedom invariant, restated for pruning
This is the one place pruning could break the system, so it is the load-bearing paragraph.
- A pruned node resolves to skip, not
notReady. LeftnotReady,allSettlednever holds and the run never completes (decider.ts:293-296). - Skip propagates through a losing region exactly as an untaken branch arm's skip does today. The by-construction argument survives untouched: every node still resolves to skip or done in finitely many topological steps, so nothing waits on an arm that will never settle.
- Re-convergence is safe: a node fed by a pruned arm (skip) AND the merge (taken) still
runs — one taken inbound edge is sufficient (
decider.ts:206). A node fed only by pruned arms skips, and so does its subtree.
6 · Owner decision — both semantics ship, one setting
Decided 2026-08-09. "For the losing arms, make both options available via a setting on the merge box." So §5's analysis no longer selects the only behaviour — it selects the DEFAULT.
| Field | Shape |
|---|---|
mode | "wait_all" | "wait_any" — unchanged |
on_loss | "prune" | "continue", default "prune". Legal only on wait_any — a wait_all merge has no losers, so the field would govern nothing |
Vocabulary: snake_case key with short lowercase values, matching
collect: all|last|none and mode: sequential|parallel
(graph.ts:152). on_loss names the event — an arm lost the race — rather
than the mechanism, so a future third policy has somewhere to live.
The default is a design position, not a coin toss. A default that keeps spending and keeps ACTING is the wrong default for a governed product: the safe behaviour must be the one an author gets by not thinking about it, and the dangerous one must be chosen on purpose. That is the whole argument of §5, relocated to where defaults are decided.
continue — the mode with teeth
Losing arms run to completion. Their outputs are RECORDED in history and under their own node
ids exactly as any step's are (collectNodeOutputs) — the merge simply never reads
them. What must not be softened anywhere in the product: their side effects still
happen. A losing capability node still sends the email, files the ticket, moves
the money.
| Ripple | Under continue |
|---|---|
| replay determinism | Unchanged. The winner is still the earliest settle index among live arms; a loser's completion lands LATER in an append-only history, so it can never become earliest and can never change the merge's won. Loser results are in history and are not inputs to the merge. |
| budget | Losers still count against the 200-run descendant cap (run-start.ts:33), so continue can exhaust a budget the same graph would not exhaust under prune. A continue merge inside a loop_over_items fan is the shape that will find that ceiling first. |
| run canvas | Losers must not read as pruned or skipped — they are genuinely running, then done-but-unread. Plain done is also wrong: it implies the result mattered. Needs its own mark (§8). |
| downstream references | A node below the merge referencing a losing arm resolves nondeterministically under continue (it depends on whether that arm finished yet) and always-empty under prune, since an unresolved reference substitutes the empty string. Refused at publish (§7) rather than left as a silent blank. |
Note what does NOT change: prune's three layers from §5 still hold exactly as
written — stop scheduling, stop the losers' child runs, accept the uncancellable in-flight tail.
continue keeps all three off, which is why it needs no new decider machinery beyond
not pruning; its cost is entirely in the canvas and in the refusals that keep it honest.
The setting's copy — the whole reason it is dangerous mislabelled
A non-engineer picking this in the node config panel must be able to see the effects-versus-results distinction without being told it separately.
| Element | Copy |
|---|---|
| label | When another arm wins first |
| field note | Only applies when this merge releases on the first arm home. |
prune option | Stop the arms that lost — Nothing further on a losing arm starts. A step already in progress cannot be called back, so a little work may still finish. |
continue option | Let the arms that lost finish — Their results are ignored, but their actions are not: a losing step will still send the email, file the ticket, or make the payment. |
7 · Publish-time refusals
House style: every refusal names its remediation (validate.ts:311, 545).
| Refuse | Message names |
|---|---|
split with < 2 out-edges | "a split with one arm is a chain — delete the split, or wire a second arm" |
merge with < 2 in-edges | "nothing converges here — delete the merge, or wire a second arm into it" |
a labelled edge out of split, or an error edge on either kind | Neither runs an activity, so neither can fail — the branch/wait stance, and neither joins ERROR_EDGE_KINDS |
order not naming exactly the immediate successors | The missing or stale id, mirroring ui.positions rule 8 — a stale entry is an authoring error, not dead weight |
| sequential arm regions that interleave | "arm 'b1' is reachable from arm 'a1' at node 'x' — a sequential arm must be self-contained; route both into a merge instead" |
wait_any fed only by arms of ONE exclusive source | "exactly one of switch 's1''s arms is ever taken, so there is no race to win — use wait_all, or feed the merge from independent arms" |
on_loss on a wait_all merge | "a wait_all merge has no losing arms, so on_loss governs nothing here — remove it, or set mode to wait_any". The union shape refuses it too; the guided message is what an author actually reads |
a node below a wait_any merge referencing one of its ARMS | "node 'd' reads {{ node.'slow'.x }}, an arm of wait_any merge 'm1' that may not have run — under prune it is always empty, under continue it is a race. Read the merge's own output, or move the reference above the merge" |
That last one is genuinely new territory: the existing ancestor rule permits it, because a losing arm IS a graph ancestor of anything below the merge. Only the wait_any semantics make it unsound, and an unresolved reference substitutes the empty string rather than failing — exactly the silent-blank class this document refuses everywhere else.
Labelled arms into a merge are otherwise legal and unchanged: a switch arm or an
approval's denied arm entering a merge already works, because the untaken arms skip
and a wait-all waits only on live ones.
8 · Canvas
| Surface | Change |
|---|---|
| kind tokens | --kind-split, --kind-merge in light, dark, and the Tailwind bridge (index.css:60-71, 113-124, 363-374) |
| icons | GitFork and GitMerge. Note Split is already taken by switch (NodeKindChip.tsx:69) — the obvious name is the wrong one |
| ports | A split gets ONE PORT PER ARM, numbered top-to-bottom to match order — which finally makes a fan-out visible. Generalize cardHeightFor's switch special case: height = max(112, ports × 28) (port-geometry.ts:64-69). A merge keeps ONE target port; inbound edges need no distinguishing, only the winner does, and only at run time |
| palette | Two entries in KIND_ORDER beside branch/switch — the routing family (WorkflowEditor.tsx:50), plus defaultConfigFor and wire-rules |
| run overlay | See the gap below |
The run overlay is the real gap, and it is bigger than a colour.
WorkflowNodeStatus is done | running | failed | stopped | idle
(types.ts:16) — there is no skipped state at all today; a never-reached node
is dim idle. Worse, a mid-flight node is only rewritten to stopped
when the whole RUN goes terminal (run-status.ts:454-459). A wait_any run
continues after the win, so a pruned loser would spin forever on a live canvas.
Because both semantics now ship, the overlay needs two new marks, not one — and
continue's is the one that prevents a NEW lie rather than fixing an old one:
| Loser under | Reads as | Because |
|---|---|---|
prune | pruned — settled, not run, visibly deliberate | Today it would either spin forever or, once the run ends, be indistinguishable from a node the run simply never reached |
continue | done · unread — ran fully, effects happened, output not consumed | Plain done would imply its result fed the merge. Its ACTIONS did land, so it must not read as pruned or skipped either |
The merge card itself reads won · <armId> in both modes.
And the structural risk to name: there is no server-side per-node state — no
workflow_steps table. The UI re-derives every node's state client-side from the raw
engine event log, deliberately mirroring the decider
(run-status.ts:106). A new readiness rule shipped without its mirror does not throw;
the canvas just silently lies. Every phase below therefore lands decider and mirror together.
Builder-agent prompt, one sentence: "split (mode parallel|sequential) and merge (mode wait_all|wait_any) are OPTIONAL policy nodes over the fan-out and join you already get from edge count — add one only to run arms one at a time, or to release a join on the first arm home; a wait_any merge outputs {{ node.<id>.won }} and, by default, prunes the arms that lost — set on_loss to "continue" only when a losing arm's work should finish anyway, remembering its side effects still happen."
9 · Compat and the spec bump
- Graph doc: two new
kindvalues with configs. No edge-schema change — order lives in config, so rule 4b's closed label world stays closed. - Migration: none. A published v1.8 graph contains no split or merge, so it replays identically — the same argument v1.6 made for v1–v1.5.
- NEP-0006 → v1.9: node-kinds rows for both, including
on_lossand itswait_any-only rule; a "split and merge policies" subsection under Fan-out and join; the new validation rules; the pruned-arm skip rule folded into the deadlock-freedom paragraph as an extension, not an exception; and the statement that acontinueloser's recorded output is never an input to its merge. - Documentation debt to clear in the same bump: v1.8's node-kinds table is missing
transform,http_request,wait, andloop_over_items— four SHIPPED kinds (zero occurrences of the first two anywhere in the spec). Adding two more kinds on top of that drift is how a spec stops being the reference.
10 · Out of scope, stated
- Weighted / probabilistic routing — a switch-family follow-up, not a split policy.
- N-of-M quorum joins — a future merge mode.
wait_anyis quorum at N = 1, so the config keeps room:modenow, aquorum: { n }later, no rename. - Trigger-catalog strategy — its own PD.
- Cancelling an in-flight activity — needs an engine primitive that does not exist; out of scope by construction, which is exactly what §5's third step concedes.
- Merged-output shapes beyond
won. Arms keep publishing under their own node ids; a downstream node still reads each independently.
11 · Phases
- The no-op pair —
split · parallel+merge · wait_all(~2 sessions). Kinds, configs, refusals, tokens, icons, palette, per-arm split ports and card growth, spec bump including the four undocumented kinds. Zero decider change — the whole surface lands before any execution risk does. split · sequential(~2 sessions). The readiness rule, the dominator region computation, the interleave refusal, ordinal ports, and the UI mirror.merge · wait_any+on_loss, both values (~4 sessions, up from 3 — §6 grew this phase). Earliest-settle-index winner, thewonoutput, theprunepath (prune-to-skip plus the stop activity for losing child runs), thecontinuepath (leave the arms alone, and prove a late loser completion cannot alterwon), and three refusals: the degenerate race,on_lossonwait_all, and the arm-reference rule.- Run overlay — both loser states (~2 sessions, up from 1–2). The
prunedstate, thedone · unreadmark, the winner mark on the merge card, the config-panel copy from §6, and the "N of M" tally correction so neither loser state reads as pending.
Total ~10–11 sessions, up from ~8–9. The growth is real and worth naming: shipping both semantics roughly doubles phase 3's decider surface and turns phase 4 from one canvas state into two plus the copy that makes the setting safe to choose.
12 · Acceptance
- Eight publish refusals, each with its own test and its remediation sentence asserted
verbatim: split < 2 arms; merge < 2 inbound; label or error edge on either;
ordermismatch; interleaved sequential regions; degeneratewait_any;on_losson await_allmerge; a reference to await_anymerge's arm from below it. - The default is
prune— asserted as BEHAVIOUR, not as a string. A graph omittingon_lossmust produce the pruning command sequence; a mutation flipping the default tocontinuemust fail this test. Asserting only that the zod default equals"prune"would survive the mutation that matters. - Mutation-tested decider rules — each must FAIL when the rule is inverted:
region-serial mutated to node-serial; the winner's tie-break; and pruned-resolves-to-skip
mutated to
notReady(which must surface as a run that never completes, not as a green suite). - Compile-down equivalence: a graph with
split · parallelandmerge · wait_allemits the same command batches, in the same order, as the same graph with the nodes deleted and the edges wired directly. - Live gate-walk — sequential: a 3-arm sequential split of notify arms, where arm 2
has no
ActivityScheduledbefore arm 1'sActivityCompleted, proving ordering rather than asserting it. - Live gate-walk —
wait_any·prune: a fast and a slow arm, proving first-wins, the merge'swonnaming the fast arm, the slow arm's child run STOPPED, its unstarted nodes resolving to skip (noActivityScheduledfor them), and its card rendering pruned rather than spinning while the run continues. - Live gate-walk —
wait_any·continue: the same graph withon_loss: "continue", where the losing arm's step provably still executes its EFFECT — anotifywhose message is present in the channel after the merge already released — while the merge still emits the fast arm aswonand the loser's card readsdone · unread. Proving the effect happened is the point; asserting the node reacheddonewould not. - Mirror parity: a test pinning the UI's derived node states against the decider's own
outcomes for a split/merge graph — the drift class
run-status.tsinvites.