6464 * nothing read — and then refuses, naming the `function` it does not have,
6565 * instead of logging a line and reporting success as it used to.
6666 *
67- * `decision` stays export-only, deliberately: it may carry no `conditions` at
68- * all when it branches purely on edge predicates (a plain BPMN exclusive
69- * gateway), and `conditions` is its only key — so a parse would have nothing
70- * left to check. Its enforcement remains the objectui reconciliation test,
71- * which is what #4278 was actually about (a form authoring keys nothing reads).
67+ * `decision` stays export-only: nothing parses it at run time. It may carry no
68+ * `conditions` at all when it branches purely on edge predicates, its executor
69+ * reads `conditions` and nothing else, and its one other key — `mode` — is
70+ * declared AHEAD of the engine change that reads it (#15429; see
71+ * {@link DecisionConfigSchema}). Its enforcement remains the objectui
72+ * reconciliation test, which is what #4278 was actually about (a form
73+ * authoring keys nothing reads).
7274 *
7375 * Undeclared aliases are NOT part of these contracts: `subflow`'s historical
7476 * `flow` spelling graduated into the ADR-0087 D2 conversion
@@ -185,6 +187,25 @@ const DECISION_KEY_GUIDANCE: Readonly<Record<string, string>> = {
185187 + 'edges is the double-declaration this guidance exists to stop. If the edges already carry the predicate, delete this key.' ,
186188} ;
187189
190+ /**
191+ * The refusal for a `decision` `config.mode` outside the closed pair (#15429).
192+ *
193+ * One message for EVERY wrong value, instead of zod's bare list of members or
194+ * a did-you-mean: the likely wrong values are not typos an edit distance can
195+ * reach — `'all'`, `'first'`, `'first_match'`, `'parallel'`, `true` — they are
196+ * the concept spelled the way another engine spells it (n8n's switch is a
197+ * boolean toggle). So the prescription says what each legal member MEANS, not
198+ * only how it is spelled, and what leaving the key out means.
199+ */
200+ function decisionModePrescription ( input : unknown ) : string {
201+ const received = typeof input === 'string' ? `'${ input } '` : String ( JSON . stringify ( input ) ?? input ) ;
202+ return `\`mode: ${ received } \` is not a decision mode. \`mode\` is the closed pair 'exclusive' | 'inclusive'. `
203+ + "'exclusive' declares that only the FIRST out-edge whose condition holds is taken, in the order the edges "
204+ + "are declared (the BPMN exclusive gateway), and is what an omitted `mode` means. 'inclusive' declares that "
205+ + 'EVERY out-edge whose condition holds is taken (the BPMN inclusive gateway). Write one of the two, or omit '
206+ + 'the key for exclusive.' ;
207+ }
208+
188209// ─── script ──────────────────────────────────────────────────────────
189210
190211/**
@@ -390,18 +411,55 @@ export const DecisionConditionSchema = lazySchema(() => strictObject({
390411export type DecisionCondition = z . input < typeof DecisionConditionSchema > ;
391412
392413/**
393- * `decision` node config — what the executor reads.
414+ * `decision` node config — what the executor reads, plus the branch `mode`
415+ * declared ahead of the engine change that will read it.
394416 *
395- * A decision may also carry no `conditions` at all and rely purely on the
396- * OUT-EDGES (`edge.condition` per branch + `isDefault` on the fallback,
397- * evaluated by the engine's traversal) — a plain BPMN exclusive gateway, and
398- * the shape every bundled example uses. A node that declares no `conditions`
399- * reports no branch at all, so nothing competes with the edges.
417+ * A decision routes one of two ways (logic-nodes.ts, #4414):
418+ *
419+ * - it DECLARES `conditions` → the executor takes the first entry whose
420+ * expression holds, and traversal narrows to the out-edge carrying that
421+ * entry's `label`;
422+ * - it declares none → it routes purely on its OUT-EDGES (`edge.condition`
423+ * per branch + `isDefault` on the fallback, evaluated by the engine's
424+ * traversal) — the shape every bundled example uses. A node that declares
425+ * no `conditions` reports no branch at all, so nothing competes with the
426+ * edges.
400427 *
401428 * Pick **one** mechanism per decision. Declaring `conditions` here *and*
402429 * per-edge `condition`s means the node picks a branch and then that branch's
403430 * edge re-decides — the double-declaration behind #4414.
404431 *
432+ * ## `mode` — one out-edge, or every out-edge whose condition holds
433+ *
434+ * `mode` is the author's declaration of what an edge-branched decision takes
435+ * when more than one out-edge condition holds (the #15429 ruling, item 2):
436+ *
437+ * - `'exclusive'` — only the FIRST, in the order the edges are declared, and
438+ * what an omitted `mode` means: the BPMN exclusive gateway, Salesforce
439+ * Flow's Decision, n8n Switch's default;
440+ * - `'inclusive'` — EVERY one that holds: the BPMN inclusive gateway, n8n
441+ * Switch's "send to all matching outputs".
442+ *
443+ * Taking every matching branch is the one an author must write down, so a
444+ * decision whose author never considered overlapping conditions takes one
445+ * branch. `mode` has no `.default()` on purpose: the parsed output stays the
446+ * authored shape, and "omitted means exclusive" lives in this contract's
447+ * prose and in the reader that honours it. It speaks about the out-edges — the
448+ * shape the ruling addresses. A `conditions` list is ordered first-match on
449+ * its own, and the ruling leaves it so.
450+ *
451+ * ⚠️ **Declared ahead of its enforcement, and the status quo does NOT match
452+ * the default above.** The ruling's split order lands this key first, then
453+ * the engine semantics together with the `os migrate meta` conversion in one
454+ * change, then the docs. Until that second step lands, nothing reads `mode`,
455+ * and an edge-branched decision takes EVERY out-edge whose condition holds,
456+ * one after another, whatever `mode` says — the behaviour
457+ * `decision-overlapping-edge-conditions.pin.test.ts` (service-automation)
458+ * pins as the status quo. The engine change and the conversion that writes
459+ * `mode: 'inclusive'` onto every decision relying on that behaviour land
460+ * together, so no shipped flow changes behaviour silently; this paragraph is
461+ * rewritten with them (#15429).
462+ *
405463 * The legacy singular `config.condition` is a structural surface the engine
406464 * parse-validates on every node at registration but the decision executor never
407465 * reads; branching predicates live in `conditions[]` or on the edges.
@@ -414,6 +472,23 @@ export const DecisionConfigSchema = lazySchema(() => strictObject({
414472 /** Ordered branches; first true expression wins, else the declared default edge. */
415473 conditions : z . array ( DecisionConditionSchema ) . optional ( )
416474 . describe ( 'Ordered decision branches (first true expression wins; omit to branch purely on edge conditions)' ) ,
475+ /**
476+ * How many out-edges an edge-branched decision takes when more than one
477+ * condition holds — `'exclusive'` (the first; what an omitted key means) or
478+ * `'inclusive'` (every one). Not read by the engine yet: see the
479+ * "Declared ahead of its enforcement" note above. Any other value is refused
480+ * with {@link decisionModePrescription}.
481+ */
482+ mode : z . enum ( [ 'exclusive' , 'inclusive' ] , {
483+ error : ( issue ) => ( issue . code === 'invalid_value' ? decisionModePrescription ( issue . input ) : undefined ) ,
484+ } ) . optional ( )
485+ . describe (
486+ 'Declares how many out-edges an edge-branched decision takes when more than one out-edge condition holds: '
487+ + "'exclusive' = only the first, in the order the edges are declared (what an omitted mode means); "
488+ + "'inclusive' = every one that holds. Declared ahead of the engine change that reads it: until that lands, "
489+ + 'an edge-branched decision takes every out-edge whose condition holds, whatever this says. A conditions '
490+ + 'list is first-match on its own.' ,
491+ ) ,
417492} ) ) ;
418493
419494export type DecisionConfig = z . input < typeof DecisionConfigSchema > ;
0 commit comments