Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/19867-decision-config-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@objectstack/spec": minor
---

`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).

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()`).

- **`'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.
- **⚠️ 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.
- **A `conditions` list is unaffected**: it is ordered first-match on its own, and `mode` speaks about the out-edges.
- **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.
13 changes: 8 additions & 5 deletions content/docs/references/automation/schemaless-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,13 @@ The two halves reach different audiences, which is why they shipped together:
nothing read — and then refuses, naming the `function` it does not have,
instead of logging a line and reporting success as it used to.

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

Undeclared aliases are NOT part of these contracts: `subflow`'s historical
`flow` spelling graduated into the ADR-0087 D2 conversion
Expand Down Expand Up @@ -135,6 +137,7 @@ const result = DecisionConditionSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **conditions** | `{ label: string; expression: string }[]` | optional | Ordered decision branches (first true expression wins; omit to branch purely on edge conditions) |
| **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. |

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

Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/automation.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"automation/DecisionCondition:expression",
"automation/DecisionCondition:label",
"automation/DecisionConfig:conditions",
"automation/DecisionConfig:mode",
"automation/DecisionOutputDef:key",
"automation/DecisionOutputDef:label",
"automation/DecisionOutputDef:multiple",
Expand Down
87 changes: 83 additions & 4 deletions packages/spec/src/automation/schemaless-node-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
* `script` and `subflow` run through `service-automation`'s `parseNodeConfig()`
* before their executors do anything, so what this file pins is not decoration:
* a shape accepted here runs, and a shape rejected here refuses the node as a
* guard. `decision` is deliberately absent — it stays export-only (its one key
* is optional, so a parse would have nothing to check).
* guard. `decision` is the exception — it stays export-only (nothing parses it
* at run time), so its pins below bind the authoring doors only: `tsc`, the
* published JSON Schema and a direct parse. Its `mode` key is declared ahead of
* the engine change that reads it (#15429).
*
* The structural assertions at the bottom guard the downstream walkers that a
* union-shaped contract would have broken, which is why #4343 converged the
Expand All @@ -16,15 +18,18 @@
* `properties` / `.shape`.
*/

import { describe, expect, it } from 'vitest';
import { describe, expect, expectTypeOf, it } from 'vitest';
import { z } from 'zod';

import { objectStackErrorMap } from '../shared/error-map.zod.js';
import {
DecisionConditionSchema,
DecisionConfigSchema,
ScriptConfigSchema,
SubflowConfigSchema,
getSchemalessNodeConfigJsonSchemas,
type DecisionConfig,
type DecisionConfigParsed,
} from './schemaless-node-config.zod.js';

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

describe('DecisionConfigSchema.mode (#15429 item 2 — the contract half, declared ahead of the engine)', () => {
it('accepts an omitted mode and both members, and injects nothing', () => {
// No `.default('exclusive')`: "omitted means exclusive" is the contract's
// prose and the future reader's job, so the parsed output stays exactly the
// authored shape for every consumer of this schema.
const omitted = DecisionConfigSchema.parse({});
expect(omitted).toEqual({});
expect('mode' in omitted, 'an omitted mode must not come back as a parsed default').toBe(false);
expect(DecisionConfigSchema.parse({ mode: 'exclusive' })).toEqual({ mode: 'exclusive' });
expect(DecisionConfigSchema.parse({ mode: 'inclusive' })).toEqual({ mode: 'inclusive' });
// …and alongside a branch list, which the key does not forbid.
expect(DecisionConfigSchema.parse({
mode: 'exclusive',
conditions: [{ label: 'big', expression: 'amount > 100000' }],
})).toEqual({ mode: 'exclusive', conditions: [{ label: 'big', expression: 'amount > 100000' }] });
});

it('types the key as the closed pair at the tsc door', () => {
expectTypeOf<DecisionConfig['mode']>().toEqualTypeOf<'exclusive' | 'inclusive' | undefined>();
expectTypeOf<DecisionConfigParsed['mode']>().toEqualTypeOf<'exclusive' | 'inclusive' | undefined>();
});

// The values an author reaching for this concept under another engine's
// spelling writes — plus a case slip and the n8n-style boolean. Each is
// refused with the SAME prescription, which names both members' meanings
// and what an omitted key means.
const REFUSED: ReadonlyArray<[unknown, string]> = [
['all', "`mode: 'all'`"],
['first', "`mode: 'first'`"],
['parallel', "`mode: 'parallel'`"],
['Inclusive', "`mode: 'Inclusive'`"],
[true, '`mode: true`'],
[null, '`mode: null`'],
];

it.each(REFUSED)('refuses mode %j with the prescription at path [mode]', (value, echoed) => {
const result = DecisionConfigSchema.safeParse({ mode: value });
expect(result.success).toBe(false);
const issues = result.error!.issues;
expect(issues).toHaveLength(1);
expect(issues[0]!.code).toBe('invalid_value');
expect(issues[0]!.path).toEqual(['mode']);
const message = issues[0]!.message;
expect(message).toContain(`${echoed} is not a decision mode`);
expect(message).toContain("the closed pair 'exclusive' | 'inclusive'");
expect(message).toContain('what an omitted `mode` means');
expect(message, 'a prescription an author is shown carries no tracker number').not.toMatch(/#\d{3,5}/);
});

it('keeps its prescription under the ObjectStack error map a validator may pass per parse', () => {
// A schema-level `error` outranks a per-parse map in zod 4, so the generic
// "Invalid value … Expected one of" text must not replace the prescription.
const result = DecisionConfigSchema.safeParse({ mode: 'all' }, { error: objectStackErrorMap });
expect(result.success).toBe(false);
expect(result.error!.issues[0]!.message).toContain("`mode: 'all'` is not a decision mode");
});

it('publishes mode as an optional closed enum with no default in the JSON Schema', () => {
const json = getSchemalessNodeConfigJsonSchemas().decision as Record<string, unknown>;
const mode = (json.properties as Record<string, Record<string, unknown>>).mode;
expect(mode.enum).toEqual(['exclusive', 'inclusive']);
expect(mode.default, 'a JSON-Schema default would read as an enforced one').toBeUndefined();
expect(json.required ?? [], 'mode is optional').not.toContain('mode');
for (const combinator of ['anyOf', 'oneOf', 'allOf']) {
expect(json[combinator], `top-level ${combinator} would blind the authorable-surface walk`).toBeUndefined();
}
});
});

describe('structural contract — what the downstream walkers require', () => {
it('keeps the tombstoned keys IN the shape, so the ratchet can see them retired', () => {
// A `retiredKey()` is still a property. Deleting it outright would read as
Expand Down Expand Up @@ -265,7 +339,12 @@ describe('structural contract — what the downstream walkers require', () => {
// lands where the edit is made.
expect(Object.keys(SubflowConfigSchema.shape).sort())
.toEqual(['flowName', 'input', 'outputVariable']);
expect(Object.keys(DecisionConfigSchema.shape).sort()).toEqual(['conditions']);
// `decision` gained `mode` (#15429, item 2) — a deliberate key-set change,
// not drift. objectui's reconciliation reads `.shape` in both directions,
// so its `decision` panel reds on the objectui spec bump that carries this
// key until its hand-written form offers `mode` too; that is the cross-repo
// half this pin exists to make visible here.
expect(Object.keys(DecisionConfigSchema.shape).sort()).toEqual(['conditions', 'mode']);
expect(Object.keys(DecisionConditionSchema.shape).sort()).toEqual(['expression', 'label']);
});

Expand Down
97 changes: 86 additions & 11 deletions packages/spec/src/automation/schemaless-node-config.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,13 @@
* nothing read — and then refuses, naming the `function` it does not have,
* instead of logging a line and reporting success as it used to.
*
* `decision` stays export-only, deliberately: it may carry no `conditions` at
* all when it branches purely on edge predicates (a plain BPMN exclusive
* gateway), and `conditions` is its only key — so a parse would have nothing
* left to check. Its enforcement remains the objectui reconciliation test,
* which is what #4278 was actually about (a form authoring keys nothing reads).
* `decision` stays export-only: nothing parses it at run time. It may carry no
* `conditions` at all when it branches purely on edge predicates, its executor
* reads `conditions` and nothing else, and its one other key — `mode` — is
* declared AHEAD of the engine change that reads it (#15429; see
* {@link DecisionConfigSchema}). Its enforcement remains the objectui
* reconciliation test, which is what #4278 was actually about (a form
* authoring keys nothing reads).
*
* Undeclared aliases are NOT part of these contracts: `subflow`'s historical
* `flow` spelling graduated into the ADR-0087 D2 conversion
Expand Down Expand Up @@ -185,6 +187,25 @@ const DECISION_KEY_GUIDANCE: Readonly<Record<string, string>> = {
+ 'edges is the double-declaration this guidance exists to stop. If the edges already carry the predicate, delete this key.',
};

/**
* The refusal for a `decision` `config.mode` outside the closed pair (#15429).
*
* One message for EVERY wrong value, instead of zod's bare list of members or
* a did-you-mean: the likely wrong values are not typos an edit distance can
* reach — `'all'`, `'first'`, `'first_match'`, `'parallel'`, `true` — they are
* the concept spelled the way another engine spells it (n8n's switch is a
* boolean toggle). So the prescription says what each legal member MEANS, not
* only how it is spelled, and what leaving the key out means.
*/
function decisionModePrescription(input: unknown): string {
const received = typeof input === 'string' ? `'${input}'` : String(JSON.stringify(input) ?? input);
return `\`mode: ${received}\` 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.';
}

// ─── script ──────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -390,18 +411,55 @@ export const DecisionConditionSchema = lazySchema(() => strictObject({
export type DecisionCondition = z.input<typeof DecisionConditionSchema>;

/**
* `decision` node config — what the executor reads.
* `decision` node config — what the executor reads, plus the branch `mode`
* declared ahead of the engine change that will read it.
*
* A decision may also carry no `conditions` at all and rely purely on the
* OUT-EDGES (`edge.condition` per branch + `isDefault` on the fallback,
* evaluated by the engine's traversal) — a plain BPMN exclusive gateway, and
* the shape every bundled example uses. A node that declares no `conditions`
* reports no branch at all, so nothing competes with the edges.
* A decision routes one of two ways (logic-nodes.ts, #4414):
*
* - it DECLARES `conditions` → the executor takes the first entry whose
* expression holds, and traversal narrows to the out-edge carrying that
* entry's `label`;
* - it declares none → it routes purely on its OUT-EDGES (`edge.condition`
* per branch + `isDefault` on the fallback, evaluated by the engine's
* traversal) — the shape every bundled example uses. A node that declares
* no `conditions` reports no branch at all, so nothing competes with the
* edges.
*
* Pick **one** mechanism per decision. Declaring `conditions` here *and*
* per-edge `condition`s means the node picks a branch and then that branch's
* edge re-decides — the double-declaration behind #4414.
*
* ## `mode` — one out-edge, or every out-edge whose condition holds
*
* `mode` is the author's declaration of what an edge-branched decision takes
* when more than one out-edge condition holds (the #15429 ruling, item 2):
*
* - `'exclusive'` — only the FIRST, in the order the edges are declared, and
* what an omitted `mode` means: the BPMN exclusive gateway, Salesforce
* Flow's Decision, n8n Switch's default;
* - `'inclusive'` — EVERY one that holds: the BPMN inclusive gateway, n8n
* Switch's "send to all matching outputs".
*
* Taking every matching branch is the one an author must write down, so a
* decision whose author never considered overlapping conditions takes one
* branch. `mode` has no `.default()` on purpose: the parsed output stays the
* authored shape, and "omitted means exclusive" lives in this contract's
* prose and in the reader that honours it. It speaks about the out-edges — the
* shape the ruling addresses. A `conditions` list is ordered first-match on
* its own, and the ruling leaves it so.
*
* ⚠️ **Declared ahead of its enforcement, and the status quo does NOT match
* the default above.** 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 lands, nothing reads `mode`,
* and an edge-branched decision takes EVERY out-edge whose condition holds,
* one after another, whatever `mode` says — the behaviour
* `decision-overlapping-edge-conditions.pin.test.ts` (service-automation)
* pins as the status quo. The engine change and the conversion that writes
* `mode: 'inclusive'` onto every decision relying on that behaviour land
* together, so no shipped flow changes behaviour silently; this paragraph is
* rewritten with them (#15429).
*
* The legacy singular `config.condition` is a structural surface the engine
* parse-validates on every node at registration but the decision executor never
* reads; branching predicates live in `conditions[]` or on the edges.
Expand All @@ -414,6 +472,23 @@ export const DecisionConfigSchema = lazySchema(() => strictObject({
/** Ordered branches; first true expression wins, else the declared default edge. */
conditions: z.array(DecisionConditionSchema).optional()
.describe('Ordered decision branches (first true expression wins; omit to branch purely on edge conditions)'),
/**
* How many out-edges an edge-branched decision takes when more than one
* condition holds — `'exclusive'` (the first; what an omitted key means) or
* `'inclusive'` (every one). Not read by the engine yet: see the
* "Declared ahead of its enforcement" note above. Any other value is refused
* with {@link decisionModePrescription}.
*/
mode: z.enum(['exclusive', 'inclusive'], {
error: (issue) => (issue.code === 'invalid_value' ? decisionModePrescription(issue.input) : undefined),
}).optional()
.describe(
'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.',
),
}));

export type DecisionConfig = z.input<typeof DecisionConfigSchema>;
Expand Down
Loading