Skip to content

Commit 7419430

Browse files
committed
feat(spec): declare an optional decision config mode ('exclusive' | 'inclusive')
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
1 parent 8d1f7ab commit 7419430

2 files changed

Lines changed: 169 additions & 15 deletions

File tree

‎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)