PD009 · neutron design brief · stage: accepted

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.

stage · accepted decided · implicit topology stays legal forever decided · nodes, not edge annotations — an unseen policy is ungoverned decided · ship WITH the first policies, not as empty boxes decided · BOTH loser semantics ship, per-merge on_loss (§6) decided · default prune — a design position, not a coin toss grounds · NEP-0006 v1.8 · the frontier decider tracking · neutron#484

1 · What already ships, and why nobody can see it

Four rules, all live, all load-bearing for everything below.

RuleTodayWhere
fan-outEdge COUNT, not a node kind. A node with N unlabelled out-edges runs all N once it completes.NEP-0006 §Fan-out (v1.6)
joinImplicit. ANY node with >1 live inbound edge wait-alls. No node, no config — readiness is in-degree.decider.ts:201-209
skip-propagationAn 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 failureA 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.

capabilityone port · 3 edges
all 3, concurrently
3 armsno order, no policy
in-degree 3
notifyimplicit wait-all
PD009 adds
split · mergethe policy carrier
ships today this PD

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:

  1. 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/
  2. 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
  3. The mode flip must not be a rewire. With the node present, parallelsequential is 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 · modeCompiles down toDecider
split · parallelIts N unlabelled out-edges — today's fan-out, byte for bytenone — settles immediately, schedules nothing (the branch/switch decider-only pattern)
merge · wait_allin-degree > 1 wait-all — today's implicit joinnone — same pass-through
split · sequentialArm k is taken only once every arm < k has settlednew readiness rule
merge · wait_anyFirst 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 · sequentialsplit · sequential
what iteratesItems of one array, against ONE target workflowDistinct arms of THIS graph
each unit isA child RUN via decideChildRunAn ordinary node with its own topo seq
seq allocationItem-indexed disjoint strides (20_000 + seq*64 + i)Nothing new — arms already have seqs
sharedThe 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:

WantAlready expressible
stop on first arm failureInherited, 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 armAn 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:

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.

  1. 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
  2. Stop the losers' child runs. A losing arm's live sub_workflow/loop_over_items children ARE runs, so the shipped cascade reaches them — invoked from an activity, never the pure decider.run-stop.ts:70-107
  3. 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.

Rippleprune — the defaultcontinue — opt in
replay determinismWinner = 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.
budgetUnstarted 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-propagationPruned = skip, never notReady. See the invariant below.Untouched — losers settle normally.
side effectsBounded to steps already claimed at the moment of the win.Unbounded — every action on every losing arm still happens.
run canvasNeeds 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.

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.

FieldShape
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.

RippleUnder continue
replay determinismUnchanged. 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.
budgetLosers 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 canvasLosers 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 referencesA 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.

ElementCopy
labelWhen another arm wins first
field noteOnly applies when this merge releases on the first arm home.
prune optionStop 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 optionLet 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).

RefuseMessage 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 kindNeither runs an activity, so neither can fail — the branch/wait stance, and neither joins ERROR_EDGE_KINDS
order not naming exactly the immediate successorsThe 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

SurfaceChange
kind tokens--kind-split, --kind-merge in light, dark, and the Tailwind bridge (index.css:60-71, 113-124, 363-374)
iconsGitFork and GitMerge. Note Split is already taken by switch (NodeKindChip.tsx:69) — the obvious name is the wrong one
portsA 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
paletteTwo entries in KIND_ORDER beside branch/switch — the routing family (WorkflowEditor.tsx:50), plus defaultConfigFor and wire-rules
run overlaySee 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 underReads asBecause
prunepruned — settled, not run, visibly deliberateToday it would either spin forever or, once the run ends, be indistinguishable from a node the run simply never reached
continuedone · unread — ran fully, effects happened, output not consumedPlain 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

10 · Out of scope, stated

11 · Phases

  1. 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.
  2. split · sequential (~2 sessions). The readiness rule, the dominator region computation, the interleave refusal, ordinal ports, and the UI mirror.
  3. merge · wait_any + on_loss, both values (~4 sessions, up from 3 — §6 grew this phase). Earliest-settle-index winner, the won output, the prune path (prune-to-skip plus the stop activity for losing child runs), the continue path (leave the arms alone, and prove a late loser completion cannot alter won), and three refusals: the degenerate race, on_loss on wait_all, and the arm-reference rule.
  4. Run overlay — both loser states (~2 sessions, up from 1–2). The pruned state, the done · unread mark, 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