Skip to content

Commit 369bcbe

Browse files
feat(spec): DecisionConfigSchema declares an optional mode: 'exclusive' | 'inclusive' (contract half of the #15429 ruling) (#20162)
Fixes #19867 Clause-②: yes The contract half of the maintainer ruling on #15429 (record 5793803317, item 2), landed first by that ruling's own split order: spec key first, then the engine semantics and the `os migrate meta` conversion together in one PR, then the docs. This PR carries the spec key ONLY. The traversal change, the conversion, the lint hint, the status-quo pin rewrite and `flows.mdx` all stay with #15429 and are not touched here. Ruling text, verbatim (item 2): > **显式包容**:要「所有成立的分支都走」,作者必须在判断节点上**显式声明**(工作名 `mode: 'inclusive'`,对应 BPMN 包容网关、n8n 的那个开关)。这是 `DecisionConfigSchema` 上一个可选键 ⇒ `packages/spec` 契约变更,`Clause-②: yes`,实现 PR 走 `needs:contract-review`。 ## What changes `DecisionConfigSchema` (`packages/spec/src/automation/schemaless-node-config.zod.ts`) gains one optional key: ```ts mode: z.enum(['exclusive', 'inclusive'], { error: (issue) => (issue.code === 'invalid_value' ? decisionModePrescription(issue.input) : undefined), }).optional() ``` - **Accepted**: `mode` omitted, `'exclusive'`, `'inclusive'`, and either one next to a `conditions` list. - **Refused**: every other value, at path `['mode']`, issue code `invalid_value`, with one prescription. - **No `.default('exclusive')`, on purpose.** A default would change the parsed output every consumer of this schema sees: `parse({})` would start returning `{ mode: 'exclusive' }`. "Omitted means exclusive" lives in the contract's prose and in the reader that will honour it. It is pinned: `parse({})` returns `{}` with no `mode` key, and the published JSON Schema carries no `default`. - **Types**: `DecisionConfig['mode']` and `DecisionConfigParsed['mode']` are both `'exclusive' | 'inclusive' | undefined`. ### New `.describe()` text (quoted in full) > Declares how many out-edges an edge-branched decision takes when more than one out-edge condition holds: 'exclusive' = only the first, in the order the edges are declared (what an omitted mode means); 'inclusive' = every one that holds. Declared ahead of the engine change that reads it: until that lands, an edge-branched decision takes every out-edge whose condition holds, whatever this says. A conditions list is first-match on its own. ### New refusal prescription (quoted in full, rendered for input `'all'`) > `mode: 'all'` is not a decision mode. `mode` is the closed pair 'exclusive' | 'inclusive'. 'exclusive' declares that only the FIRST out-edge whose condition holds is taken, in the order the edges are declared (the BPMN exclusive gateway), and is what an omitted `mode` means. 'inclusive' declares that EVERY out-edge whose condition holds is taken (the BPMN inclusive gateway). Write one of the two, or omit the key for exclusive. This is one message for every wrong value, not a did-you-mean. The likely wrong values (`'all'`, `'first'`, `'parallel'`, `true`) are not typos an edit distance can reach. They are the same idea written the way another engine writes it, so the message explains what each legal value means. The message is a schema-level `error`, so it also wins over the `objectStackErrorMap` a validator may pass per parse. The ablation below shows that without it, that map's generic "Invalid value" text would take its place. ### Docblocks, and why this wording (the measured choice the dispatch asked for) The card and triage note 2 ask that the text not claim first-match before the engine does. I measured the window first: - `logic-nodes.ts` (the decision executor) reads `config.conditions` and nothing else. - The engine's traversal (`engine.ts`, the conditional-edge loop) reads no decision config key at all. - So at this head nothing reads `mode`. An edge-branched decision takes every out-edge whose condition holds, one after another, whatever `mode` says. `decision-overlapping-edge-conditions.pin.test.ts` pins exactly that as the status quo, and it stays green here (numbers below). The wording therefore does three things: 1. **It states what each value declares.** It does not say what the engine does. 2. **It says in the author-facing text itself that nothing reads the key yet**, and what the engine does in the meantime. This is in the `.describe()`, the docblock and the changeset. 3. **It removes the existing false statement instead of repeating it.** Before this PR, the module header and the `DecisionConfigSchema` docblock both called the edge-branched shape "a plain BPMN exclusive gateway". That is the #15429 defect written as fact. Both sentences are rewritten. The module header's "`conditions` is its only key" becomes false with this PR, so that sentence is rewritten too. On scope: `mode` speaks about the out-edges. The ruling addresses exactly that shape: its item 1 is about a decision with no `config.conditions`, and its conversion rewrites exactly those decisions. A `conditions` list is already first-match (`logic-nodes.ts`), and the ruling leaves it so. What `mode: 'inclusive'` should do next to a `conditions` list is NOT decided here. It is an open question for #15429 (see Acceptance notes). This PR neither forbids the combination nor gives it a meaning. The docblock paragraph that starts "Declared ahead of its enforcement" says it is rewritten together with #15429's engine change. That is where the ruling's item 5 puts the rewrite. ## Tests (pinned at `5fbb7ed8`) New `describe('DecisionConfigSchema.mode …')` in `packages/spec/src/automation/schemaless-node-config.test.ts`: - **Accepts**: `{}` is returned as `{}` with no `mode` injected; `{ mode: 'exclusive' }`; `{ mode: 'inclusive' }`; `mode` next to `conditions`. - **Refuses**: `'all'`, `'first'`, `'parallel'`, `'Inclusive'`, `true` and `null`. Each case checks all three parts of the refusal: exactly one issue, `code === 'invalid_value'`, `path` equal to `['mode']`, and the prescription (the echoed input, the closed pair, and what an omitted key means). Each also checks the message carries no tracker number. - **Error-map precedence**: the prescription survives `safeParse(…, { error: objectStackErrorMap })`. - **tsc door**: `expectTypeOf` pins the closed pair on both `DecisionConfig` and `DecisionConfigParsed`. - **JSON Schema**: `mode` is `enum: ['exclusive', 'inclusive']` with no `default`, not `required`, and there is no top-level `anyOf`/`oneOf`/`allOf`. - **Key-set pin updated on purpose**: `['conditions']` becomes `['conditions', 'mode']`. The pin's comment now names the cross-repo consequence (see Acceptance notes). Runs (all through `os-verify-lock`; exit codes captured before any pipe): | What | Result | |:--|:--| | `@objectstack/spec` full suite (`vitest run --project local`) | **540 files / 15,807 passed, 2 todo** | | `@objectstack/spec` `typecheck` (tsc, scripts program, test program) | exit 0. The test file is in `tsconfig.test.json`'s program (`--listFilesOnly`: 1 hit among 511 test files). `check:test-typecheck` holds this file at its 1 pre-existing pinned signature, so the new `expectTypeOf` lines add no errors | | Consumer: `@objectstack/service-automation`, full | **145 files / 1,729 passed**. It includes `config-expression-ledger.test.ts` (the only test there reading `getSchemalessNodeConfigJsonSchemas()`), `decision-overlapping-edge-conditions.pin.test.ts` (the status-quo pin: still green, so no behaviour moved), `decision-branch-routing.test.ts`, `logic-nodes.test.ts` and `config-schemas.test.ts` | | Consumer: `@objectstack/metadata-protocol`, full | **188 files passed, 3 skipped (env-gated) / 2,686 tests passed, 19 skipped**. It includes `reference-sites.derivation.test.ts`: `reference-sites.ts` walks `SCHEMALESS_NODE_CONFIG_SCHEMAS`, and an enum is not a name-shaped site | | `@objectstack/lint` | not a consumer: nothing in `packages/lint` imports `DecisionConfigSchema` or `SCHEMALESS_NODE_CONFIG_SCHEMAS` (repo-wide `git grep` of both names) | **Ablation (one-time proof, no permanent file).** Done after the commit, through `scripts/ablation-replace.mjs`. The mutation replaced the schema-level `error` with `error: () => undefined`. Anchor hits went 1 → 0 and the blob changed `67cb1f11` → `02b7fc37`. The file then read **7 failed / 30 passed**: the six refusal cases plus the error-map case. After the restore, `git hash-object` equals the HEAD blob `67cb1f11`, and `git diff HEAD` is empty. **Reverse verification against the REBUILT `dist/automation/index.d.ts`.** A scratch program outside the repo assigned `{ mode: 'all' }` to `DecisionConfig`. tsc exited 2 with `TS2322: Type '"all"' is not assignable to type '"inclusive" | "exclusive" | undefined'`. The control leg, with only `{ mode: 'inclusive' }`, exited 0. From `service-automation`'s own resolution of `@objectstack/spec/automation` (the rebuilt dist), `{ mode: 'all' }` is refused with `invalid_value` at `['mode']`, and `{ mode: 'inclusive' }` is accepted. ### Generated artifacts Regenerated, not hand-edited: - `packages/spec/authorable-surface/automation.json`: +`automation/DecisionConfig:mode` (from the build). - `content/docs/references/automation/schemaless-node-config.mdx`: from `gen:docs`, after `check:generated` named it the one stale artifact. `authorable-surface.base.json` is untouched. `check:generated` then passes all 15 artifacts, including `check:liveness` and `check:authorable-surface`. ### Gates Derived from the actual change set with `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `5fbb7ed8`: 107 commands, every one run with its exit code written to disk. `--ran` reconciliation: **107 derived, 105 run green, 2 NOT MEASURED, 0 UNRUN.** - NOT MEASURED: `check:dual-build-cjs-loads`, reason: PREREQUISITE (exit 3). It needs a dist for every workspace package, which means a whole-workspace build. In its place I loaded every CJS `require` entry of `@objectstack/spec` itself: 18 loaded, 0 failed. - NOT MEASURED: `check:type-check-debt`, reason: PREREQUISITE (exit 3). It needs the whole-workspace `.d.ts` closure (lint.yml builds `./packages/*` first). This PR touches no DEBT-ledgered package's TypeScript. - Also run: the roster gates whose rosters sit under my paths (`check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`, `check:error-code-provenance`), all exit 0. - The level axis of `check-changeset-no-major` was driven offline with `--event` on a `pull_request` payload carrying THIS body. It exited 0 and printed: `LEVEL AXIS: this PR declares clause-② yes, and no package whose packages/**/src/** it moves is graded patch` (the changeset grades `@objectstack/spec` `minor`). ## Acceptance notes - **objectui, measured read-only at the `.objectui-sha` pin `f8a9d0fb`.** `flow-node-config.spec-reconciliation.test.ts` reconciles its hand-written decision form against `DecisionConfigSchema.shape` in both directions. At the pin, the decision form offers only `conditions` (plus the legacy-gated `condition`). So once objectui installs a published spec that carries `mode`, its "read by the executor but not offered by the designer form" assertion reds for `decision`. It stays red until the form offers `mode`. - This does NOT touch this repo's CI: the Console Pin Gate builds objectui at the pin and runs none of its tests. - No objectui edit here. The objectui half is best landed with or after #15429's engine change, so the designer does not offer a switch the engine ignores. - The key-set pin in this repo now names this consequence. - **Open question for #15429, not decided here**: what `mode: 'inclusive'` means on a decision that ALSO declares `conditions`. That shape already takes one branch by label, and the ruling's item 1 and its conversion address only the edge-branched shape. The options are refusing the combination (at `os validate` / registration) or giving it a meaning. Refusing is the tighter contract. - **Where the closed pair binds today**: decision config is export-only (nothing parses it at run time), so the pair is enforced by `tsc`, the published JSON Schema and a direct parse. Those are the same doors `conditions` has. A stored flow carrying `mode: 'bogus'` is not refused at run time by this PR. That parse belongs with the engine change that reads the key. - **Main moved** by 2 commits since the branch point (`9401b842`, `49144fcc`, in `packages/rest` and `packages/core` only). There is no overlap with this diff, and the merge queue rebuilds on the current main. --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 84880f9 commit 369bcbe

5 files changed

Lines changed: 190 additions & 20 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`DecisionConfigSchema` declares an optional `mode: 'exclusive' | 'inclusive'` — the contract half of the #15429 ruling. The author of a `decision` node that routes on its out-edges can now declare whether it takes only the first out-edge whose condition holds or every one of them — with taking every one as the value that must be written down, the way BPMN separates the exclusive gateway from the inclusive one and n8n's Switch keeps "send to all matching outputs" behind an off-by-default toggle (#19867).
6+
7+
Clause-②: yes (widening) — one new OPTIONAL key on a published, strict node-config schema, so the set of accepted configs grows. Nothing previously accepted is refused, no key is renamed or retired, and the parsed output of an existing config is unchanged (the key has no `.default()`).
8+
9+
- **`'exclusive'`** — only the first out-edge whose condition holds, in the order the edges are declared; this is what an omitted `mode` means. **`'inclusive'`** — every out-edge whose condition holds. Any other value is refused at `mode` with a prescription naming both members' meanings.
10+
- **⚠️ Declared ahead of its enforcement, on purpose.** The ruling's split order lands this key first, then the engine semantics together with the `os migrate meta` conversion in one change, then the docs. Until that second step ships, nothing reads `mode`: an edge-branched decision still takes EVERY out-edge whose condition holds, whatever `mode` says. The key's own description says so, and the conversion that writes `mode: 'inclusive'` onto every decision relying on today's behaviour ships in the same change as the new traversal, so no flow changes behaviour silently.
11+
- **A `conditions` list is unaffected**: it is ordered first-match on its own, and `mode` speaks about the out-edges.
12+
- **Where it binds today**: `decision` config stays export-only (nothing parses it at run time), so the closed pair is enforced by `tsc`, by the published JSON Schema and by a direct parse — the same doors `conditions` has.

‎content/docs/references/automation/schemaless-node-config.mdx‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,13 @@ The two halves reach different audiences, which is why they shipped together:
6666
nothing read — and then refuses, naming the `function` it does not have,
6767
instead of logging a line and reporting success as it used to.
6868

69-
`decision` stays export-only, deliberately: it may carry no `conditions` at
70-
all when it branches purely on edge predicates (a plain BPMN exclusive
71-
gateway), and `conditions` is its only key — so a parse would have nothing
72-
left to check. Its enforcement remains the objectui reconciliation test,
73-
which is what #4278 was actually about (a form authoring keys nothing reads).
69+
`decision` stays export-only: nothing parses it at run time. It may carry no
70+
`conditions` at all when it branches purely on edge predicates, its executor
71+
reads `conditions` and nothing else, and its one other key — `mode` — is
72+
declared AHEAD of the engine change that reads it (#15429; see
73+
`DecisionConfigSchema`). Its enforcement remains the objectui
74+
reconciliation test, which is what #4278 was actually about (a form
75+
authoring keys nothing reads).
7476

7577
Undeclared aliases are NOT part of these contracts: `subflow`'s historical
7678
`flow` spelling graduated into the ADR-0087 D2 conversion
@@ -135,6 +137,7 @@ const result = DecisionConditionSchema.parse(data);
135137
| Property | Type | Required | Description |
136138
| :--- | :--- | :--- | :--- |
137139
| **conditions** | `{ label: string; expression: string }[]` | optional | Ordered decision branches (first true expression wins; omit to branch purely on edge conditions) |
140+
| **mode** | `Enum<'exclusive' \| 'inclusive'>` | optional | Declares how many out-edges an edge-branched decision takes when more than one out-edge condition holds: 'exclusive' = only the first, in the order the edges are declared (what an omitted mode means); 'inclusive' = every one that holds. Declared ahead of the engine change that reads it: until that lands, an edge-branched decision takes every out-edge whose condition holds, whatever this says. A conditions list is first-match on its own. |
138141

139142
### Nested Shape: `DecisionConfig.conditions[number]`
140143

‎packages/spec/authorable-surface/automation.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"automation/DecisionCondition:expression",
9090
"automation/DecisionCondition:label",
9191
"automation/DecisionConfig:conditions",
92+
"automation/DecisionConfig:mode",
9293
"automation/DecisionOutputDef:key",
9394
"automation/DecisionOutputDef:label",
9495
"automation/DecisionOutputDef:multiple",

‎packages/spec/src/automation/schemaless-node-config.test.ts‎

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
* `script` and `subflow` run through `service-automation`'s `parseNodeConfig()`
77
* before their executors do anything, so what this file pins is not decoration:
88
* a shape accepted here runs, and a shape rejected here refuses the node as a
9-
* guard. `decision` is deliberately absent — it stays export-only (its one key
10-
* is optional, so a parse would have nothing to check).
9+
* guard. `decision` is the exception — it stays export-only (nothing parses it
10+
* at run time), so its pins below bind the authoring doors only: `tsc`, the
11+
* published JSON Schema and a direct parse. Its `mode` key is declared ahead of
12+
* the engine change that reads it (#15429).
1113
*
1214
* The structural assertions at the bottom guard the downstream walkers that a
1315
* union-shaped contract would have broken, which is why #4343 converged the
@@ -16,15 +18,18 @@
1618
* `properties` / `.shape`.
1719
*/
1820

19-
import { describe, expect, it } from 'vitest';
21+
import { describe, expect, expectTypeOf, it } from 'vitest';
2022
import { z } from 'zod';
2123

24+
import { objectStackErrorMap } from '../shared/error-map.zod.js';
2225
import {
2326
DecisionConditionSchema,
2427
DecisionConfigSchema,
2528
ScriptConfigSchema,
2629
SubflowConfigSchema,
2730
getSchemalessNodeConfigJsonSchemas,
31+
type DecisionConfig,
32+
type DecisionConfigParsed,
2833
} from './schemaless-node-config.zod.js';
2934

3035
interface Parseable { safeParse(v: unknown): { success: boolean; error?: { issues: ReadonlyArray<{ code: string; message: string }> } } }
@@ -229,6 +234,75 @@ describe('unknown keys — closed at #4001 批 9, and this class had no other ga
229234
});
230235
});
231236

237+
describe('DecisionConfigSchema.mode (#15429 item 2 — the contract half, declared ahead of the engine)', () => {
238+
it('accepts an omitted mode and both members, and injects nothing', () => {
239+
// No `.default('exclusive')`: "omitted means exclusive" is the contract's
240+
// prose and the future reader's job, so the parsed output stays exactly the
241+
// authored shape for every consumer of this schema.
242+
const omitted = DecisionConfigSchema.parse({});
243+
expect(omitted).toEqual({});
244+
expect('mode' in omitted, 'an omitted mode must not come back as a parsed default').toBe(false);
245+
expect(DecisionConfigSchema.parse({ mode: 'exclusive' })).toEqual({ mode: 'exclusive' });
246+
expect(DecisionConfigSchema.parse({ mode: 'inclusive' })).toEqual({ mode: 'inclusive' });
247+
// …and alongside a branch list, which the key does not forbid.
248+
expect(DecisionConfigSchema.parse({
249+
mode: 'exclusive',
250+
conditions: [{ label: 'big', expression: 'amount > 100000' }],
251+
})).toEqual({ mode: 'exclusive', conditions: [{ label: 'big', expression: 'amount > 100000' }] });
252+
});
253+
254+
it('types the key as the closed pair at the tsc door', () => {
255+
expectTypeOf<DecisionConfig['mode']>().toEqualTypeOf<'exclusive' | 'inclusive' | undefined>();
256+
expectTypeOf<DecisionConfigParsed['mode']>().toEqualTypeOf<'exclusive' | 'inclusive' | undefined>();
257+
});
258+
259+
// The values an author reaching for this concept under another engine's
260+
// spelling writes — plus a case slip and the n8n-style boolean. Each is
261+
// refused with the SAME prescription, which names both members' meanings
262+
// and what an omitted key means.
263+
const REFUSED: ReadonlyArray<[unknown, string]> = [
264+
['all', "`mode: 'all'`"],
265+
['first', "`mode: 'first'`"],
266+
['parallel', "`mode: 'parallel'`"],
267+
['Inclusive', "`mode: 'Inclusive'`"],
268+
[true, '`mode: true`'],
269+
[null, '`mode: null`'],
270+
];
271+
272+
it.each(REFUSED)('refuses mode %j with the prescription at path [mode]', (value, echoed) => {
273+
const result = DecisionConfigSchema.safeParse({ mode: value });
274+
expect(result.success).toBe(false);
275+
const issues = result.error!.issues;
276+
expect(issues).toHaveLength(1);
277+
expect(issues[0]!.code).toBe('invalid_value');
278+
expect(issues[0]!.path).toEqual(['mode']);
279+
const message = issues[0]!.message;
280+
expect(message).toContain(`${echoed} is not a decision mode`);
281+
expect(message).toContain("the closed pair 'exclusive' | 'inclusive'");
282+
expect(message).toContain('what an omitted `mode` means');
283+
expect(message, 'a prescription an author is shown carries no tracker number').not.toMatch(/#\d{3,5}/);
284+
});
285+
286+
it('keeps its prescription under the ObjectStack error map a validator may pass per parse', () => {
287+
// A schema-level `error` outranks a per-parse map in zod 4, so the generic
288+
// "Invalid value … Expected one of" text must not replace the prescription.
289+
const result = DecisionConfigSchema.safeParse({ mode: 'all' }, { error: objectStackErrorMap });
290+
expect(result.success).toBe(false);
291+
expect(result.error!.issues[0]!.message).toContain("`mode: 'all'` is not a decision mode");
292+
});
293+
294+
it('publishes mode as an optional closed enum with no default in the JSON Schema', () => {
295+
const json = getSchemalessNodeConfigJsonSchemas().decision as Record<string, unknown>;
296+
const mode = (json.properties as Record<string, Record<string, unknown>>).mode;
297+
expect(mode.enum).toEqual(['exclusive', 'inclusive']);
298+
expect(mode.default, 'a JSON-Schema default would read as an enforced one').toBeUndefined();
299+
expect(json.required ?? [], 'mode is optional').not.toContain('mode');
300+
for (const combinator of ['anyOf', 'oneOf', 'allOf']) {
301+
expect(json[combinator], `top-level ${combinator} would blind the authorable-surface walk`).toBeUndefined();
302+
}
303+
});
304+
});
305+
232306
describe('structural contract — what the downstream walkers require', () => {
233307
it('keeps the tombstoned keys IN the shape, so the ratchet can see them retired', () => {
234308
// A `retiredKey()` is still a property. Deleting it outright would read as
@@ -265,7 +339,12 @@ describe('structural contract — what the downstream walkers require', () => {
265339
// lands where the edit is made.
266340
expect(Object.keys(SubflowConfigSchema.shape).sort())
267341
.toEqual(['flowName', 'input', 'outputVariable']);
268-
expect(Object.keys(DecisionConfigSchema.shape).sort()).toEqual(['conditions']);
342+
// `decision` gained `mode` (#15429, item 2) — a deliberate key-set change,
343+
// not drift. objectui's reconciliation reads `.shape` in both directions,
344+
// so its `decision` panel reds on the objectui spec bump that carries this
345+
// key until its hand-written form offers `mode` too; that is the cross-repo
346+
// half this pin exists to make visible here.
347+
expect(Object.keys(DecisionConfigSchema.shape).sort()).toEqual(['conditions', 'mode']);
269348
expect(Object.keys(DecisionConditionSchema.shape).sort()).toEqual(['expression', 'label']);
270349
});
271350

‎packages/spec/src/automation/schemaless-node-config.zod.ts‎

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,13 @@
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({
390411
export 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

419494
export type DecisionConfig = z.input<typeof DecisionConfigSchema>;

0 commit comments

Comments
 (0)