Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
100 changes: 100 additions & 0 deletions .changeset/15429-decision-edge-branching-first-match.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
---
"@objectstack/spec": minor
"@objectstack/service-automation": minor
"@objectstack/lint": minor
"@objectstack/metadata-protocol": minor
"@objectstack/metadata-core": patch
---

feat(automation)!: an edge-branched `decision` is exclusive — the first out-edge whose condition holds, in declaration order, wins; `mode: 'inclusive'` takes every one (#15429)

<!-- adr-0087: registered flow-decision-mode-inclusive-explicit -->

Clause-②: yes

**BREAKING** — the run-time semantics of a shipped node type change. A `decision` node that
declares no `config.conditions` and branches on its out-edges used to take EVERY out-edge whose
condition held, one after another, while its schema, the docs and the engine's own comment all
called it an exclusive gateway; hotcrm#1555 rendered a refusal screen AND ran the conversion in
one execution. Maintainer ruling on #15429 (2026-09-23, 「跟主流对齐」): the gateway follows
BPMN's exclusive gateway, Salesforce Flow's Decision and n8n's Switch default, and taking every
true branch is a declaration the author writes down.

| | before | after |
|:--|:--|:--|
| two conditioned out-edges, both hold | both successors run, sequentially, nothing reported | the FIRST declared one runs; the second records a `skipped` step |
| `config: { mode: 'inclusive' }` | accepted, never read | every out-edge whose condition holds runs, sequentially |
| none holds | the `isDefault` edge runs | unchanged |
| `mode` beside a non-empty `conditions` list, or outside `'exclusive' \| 'inclusive'` | refused by a direct parse only | refused at `registerFlow` and by `os validate`, with the schema's own sentence |

## Migration: FROM → TO

`os migrate meta --from 17` lists the mechanical edits for existing sources and applies them
to the migrated stack: the ADR-0087 D2 conversion `flow-decision-mode-inclusive-explicit`
writes `mode: 'inclusive'` onto every decision that has no `conditions` list and two or more
conditioned out-edges, inside ADR-0031 regions included, so a migrated flow runs exactly as it
did.

```ts
// FROM — every true out-edge ran
{ id: 'verdict', type: 'decision', label: 'Verdict?' }
// TO — what the conversion writes; delete the key where the conditions partition
{ id: 'verdict', type: 'decision', label: 'Verdict?', config: { mode: 'inclusive' } }
```

Then review each written key (the paired D3 entry `flow-decision-edge-branching-first-match`
carries the acceptance criteria): delete it where the conditions partition (`== 'a'` beside
`!= 'a'`, `>` beside `<=`, a guard beside `isDefault: true`), keep it where the flow relies on
more than one branch running for one record, and where the overlap was accidental narrow the
conditions into a partition and delete the key. `os validate` reports
`flow-decision-inclusive-overlap` on every decision that keeps the key with two or more
conditioned out-edges, so the review list is the lint output.

## BREAKING for flows stored in `sys_metadata` — maintainer ruling letter C on #15429

A `decision` node stored in `sys_metadata` (a flow built or edited in the Studio designer) with
**no `config.conditions`, no `mode`, and two or more out-edges carrying a `condition`** evaluates
**first-match** after this upgrade: where it took every out-edge whose condition held, it now takes
only the first one that holds, in the order the flow declares its edges. Nothing rewrites that row
— no stored-row migration, no cutoff, no read-path completion — because nothing about a stored row
says it was saved before the flip. The one-line fix, for a node that meant every branch:

```ts
{ id: 'route', type: 'decision', label: 'Route', config: { mode: 'inclusive' } }
```

`os migrate meta --stored` (and `POST /api/v1/meta/_migrate-stored`) lists every such node under
`decisionModeReview` — flow row, node id, label and path — on a preview and an `--apply` run
alike, and writes nothing for it: the list moves no row outcome, no count and no exit code, so an
operator can review the candidates before and after the upgrade. A node leaves the list once it
declares `mode`, either member. Every such node in the measured corpus below is a partition, where
the new meaning runs exactly what the old one did.

Authored sources and built artifacts keep the old behaviour instead, where the source's age is a
fact: `os migrate meta --from 17` writes `mode: 'inclusive'` (above), while the authoring funnel,
the automation engine's flow rehydration seam and the artifact-ingestion door all refuse the
conversion by id — a default flip replayed there would turn a decision written today against this
contract, where an omitted `mode` means exclusive, into an inclusive gateway.

## Reach, measured at landing

- Release state: the npm registry's `latest` `@objectstack/spec` is `17.4.0` (`npm view`,
2026-09-27), whose `json-schema/automation/DecisionConfig.json` declares `conditions` only —
`mode` has not shipped; `.changeset/19867-decision-config-mode.md` and
`.changeset/20168-decision-mode-beside-conditions-refused.md` are still unconsumed in this
tree. So `mode` reaches its first release together with the traversal that reads it and the
conversion that writes it; no published accept set narrows, and the registration and
`os validate` refusals narrow nothing that shipped.
- Corpus census (this repository at the branch base and `objectstack-ai/hotcrm` at `2f7b2326`,
read-only): 30 decision nodes across 48 flows; 17 have two or more conditioned out-edges and
no `mode` (the conversion's positives — every one a hand-written partition, including
hotcrm's `lead_conversion.decision_duplicate`, the #1555 node), 13 have one conditioned
out-edge (left alone), and no node of any other type carries a conditioned out-edge, so the
exclusive traversal is scoped to `decision` with nothing else to migrate.
- What the published surface gains: the D2 conversion and its D3 entry in the protocol-18
chain (`spec-changes.json`, the upgrade guide), `DecisionConfigSchema.mode`'s describe and
docblock now state the run-time semantics, and `@objectstack/lint` gains
`flow-decision-mode-invalid` (gating) and `flow-decision-inclusive-overlap` (advisory).

The traversal change is scoped to `decision` nodes: conditioned out-edges of any other node
type keep the every-true-edge traversal they had (none was measured to exist).
64 changes: 61 additions & 3 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1370,7 +1370,7 @@ Edges connect nodes and define the execution path:
A node has exactly two ways to split its path, and mixing them is what makes a
guard stop guarding (#4414).

**Branch on the edges** (BPMN exclusive gateway — the default choice):
**Branch on the edges** (BPMN gateway — the default choice):

```typescript
{ id: 'check', type: 'decision', label: 'Already converted?' }, // no config
Expand All @@ -1388,6 +1388,61 @@ in parallel with `abort` — so an already-converted lead sees the abort screen
condition by hand is the only other correct spelling; `isDefault` is the one
that stays correct when a third branch is added.

**The gateway is exclusive.** When more than one out-edge carries a
`condition`, the engine evaluates them **in the order the `edges` array
declares them** and takes the **first** one whose condition holds — the BPMN
exclusive gateway, Salesforce Flow's Decision element, n8n's Switch default.
The siblings after it are not evaluated, and each records a `skipped` step in
the run log, so a run says which branch won and which were passed over. When
none holds, the `isDefault` edge runs. Two conditions that both hold for one
record therefore run **one** branch, the earlier one; put the branch you want
to win first.

To take **every** out-edge whose condition holds — the BPMN inclusive gateway,
n8n's "send to all matching outputs" — declare it on the node:

```typescript
{ id: 'route', type: 'decision', label: 'Route', config: { mode: 'inclusive' } },
edges: [
{ id: 'e_vip', source: 'route', target: 'notify_account_manager', condition: 'lead.tier == "vip"' },
{ id: 'e_big', source: 'route', target: 'notify_finance', condition: 'lead.amount > 100000' },
]
```

A VIP lead over the threshold takes both, one after another (never in
parallel). `mode` has two members, `'exclusive'` (what an omitted key means)
and `'inclusive'`; anything else, and a `mode` written beside a `config.conditions`
list (which is first-match on its own), is refused when the flow registers and
by `os validate` (`flow-decision-mode-invalid`), with the same sentence at both
doors. `os validate` also reports `flow-decision-inclusive-overlap` on an
inclusive decision with two or more conditioned out-edges, because that is the
shape in which more than one branch can run for one record.

<Callout type="info">
**Upgrading a flow written before protocol 18.** An edge-branched decision used
to take every out-edge whose condition held. The ADR-0087 conversion
`flow-decision-mode-inclusive-explicit` writes `mode: 'inclusive'` onto every
decision with two or more conditioned out-edges and no `mode` when the chain is
replayed, so the migrated flow runs exactly as before; delete the key where the
two conditions partition (`== 'a'` beside `!= 'a'`, `>` beside `<=`), which is
the common case and the honest declaration. Nothing rewrites a flow at load: an
author who writes two branches today gets the exclusive gateway the contract
describes. Run `os migrate meta --from 17` to list the mechanical edits for
existing sources; apply them by hand.
</Callout>

<Callout type="warn">
**A flow stored from the Studio takes the new meaning (breaking).** A decision
saved in `sys_metadata` before protocol 18 — no `config.conditions`, no `mode`,
two or more out-edges with a `condition` — runs **first-match** after the
upgrade, and nothing rewrites the stored row: nothing about a row says it was
saved before the change. `os migrate meta --stored` lists every such node (flow,
node id, label and path) on a preview and an `--apply` run alike and writes
nothing for it, so you can review them before and after upgrading. Where a node
meant every branch, declare `config: { mode: 'inclusive' }` on it; declaring
`mode` either way takes it off the list.
</Callout>

**Branch on the node** (Salesforce-style decision outcomes): the node declares
`config.conditions[]` and traversal restricts itself to the out-edge whose
`label` matches the first matching entry.
Expand All @@ -1410,11 +1465,14 @@ fallback used to be silent, and a decision declaring `'Yes — already converted
against an out-edge labelled `'Yes'` is how #4414 shipped. `os validate` reports
the shape as `flow-branch-label-unmatched` at build time, along with
`flow-decision-unconditional-branch` (a guarded decision with an unconditional
sibling), `flow-default-edge-with-condition` and `flow-multiple-default-edges`.
sibling), `flow-default-edge-with-condition`, `flow-multiple-default-edges`,
`flow-decision-mode-invalid` and `flow-decision-inclusive-overlap`.

<Callout type="warn">
A decision node that declares **no** `conditions` reports no branch at all — it
is a plain gateway and its out-edges do the routing.
is a plain gateway and its out-edges do the routing: the first out-edge whose
condition holds, in declaration order, unless the node declares
`config: { mode: 'inclusive' }`.

Declaring **both** — `config.conditions` *and* per-edge `condition`s — is
redundant but not wrong: the node picks a branch, and then that branch's edge
Expand Down
12 changes: 12 additions & 0 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1203,6 +1203,18 @@ can produce, because both supply a live one.
| A flow whose rename the conflict guard refused | The old node-type token is a live name something else owns here. Rewriting would clobber that owner, so the row fails loudly naming the token — never a silent skip |
| A site the conversion chain leaves as stored because no lossless rewrite exists — above all a page filter carrying `$and` / `$or` / `$not` | Flattening a combinator changes which rows the page selects, so it is never done. Each site is printed as a `TODO` line under its row — path, block, and what blocks the rewrite — whatever the row's outcome; a row with nothing but TODOs is reported `skipped`. It does not fail the run, since no run of this pass can clear it: rewrite each site by hand |

**One thing it lists and never writes: decision nodes that changed meaning at
protocol 18.** A stored `decision` with no `config.conditions`, no `mode` and two
or more out-edges carrying a `condition` took every out-edge whose condition held
before protocol 18, and takes only the first one now. A stored row keeps that new
meaning — the conversion that writes `mode: 'inclusive'` replays over authored
sources only, where you assert the source's age, and nothing asserts a row's — so
the report lists each such node under `decisionModeReview` (flow row, node id,
label and path) for you to review before and after the upgrade, on a preview and
an `--apply` run alike. The list changes no row, no count and no exit code. Where
a node meant every branch, declare `mode: 'inclusive'` on it; declaring `mode`
either way takes it off the list.

**Flows are covered, and cost one extra plugin.** Flow-node conversions carry an
open-namespace conflict guard that has to consult the *live* executor registry
to tell a rename from a clobber, so this run boots the automation engine — in an
Expand Down
28 changes: 17 additions & 11 deletions content/docs/references/automation/schemaless-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,18 @@ 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: 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).
`decision` is parsed at **registration**, for one key (#15429): the
automation engine's `registerFlow` runs every decision node's config through
`DecisionConfigSchema` and refuses the flow on any issue rooted at
`mode` — a value outside the closed pair, or a `mode` beside a non-empty
`conditions` list — with this schema's own sentence, and `os validate`
reports the same issues as `flow-decision-mode-invalid`, so the two doors
cannot disagree. A decision may carry no `conditions` at all when it
branches purely on edge predicates; its executor reads `conditions` and
nothing else, and the engine's traversal reads `mode`. Its strictness
(unknown keys) still binds at authoring, in the published JSON Schema and in
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 All @@ -96,9 +101,10 @@ door in front of its author, and the class it structurally could not cover is
precisely the class with no second door. Closing these shapes is therefore
not a duplicate check for `script` and `subflow`; it is their first one.

`decision` is still export-only, so its strictness binds at authoring
(`tsc`), in the published JSON Schema, and in objectui's reconciliation —
not at run time. It is closed anyway, because the campaign's whole finding
`decision`'s strictness binds at authoring (`tsc`), in the published JSON
Schema, and in objectui's reconciliation — not at run time, where the
registration reader judges `mode` alone (#15429). It is closed anyway,
because the campaign's whole finding
is that a shape left open accretes a test, a form and a fixture that assert
the openness, and then closing it is a migration instead of an edit.

Expand Down Expand Up @@ -137,7 +143,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. Refused beside a non-empty conditions list, which is first-match on its own: delete mode there, or move the branches onto the out-edges, delete conditions, and keep mode. |
| **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; the siblings after it are not evaluated and record a skipped step); 'inclusive' = every one that holds, one after another. When none holds the isDefault edge runs either way. Refused beside a non-empty conditions list, which is first-match on its own: delete mode there, or move the branches onto the out-edges, delete conditions, and keep mode. Authored sources written while every true branch ran keep that behaviour through the os migrate meta --from 17 conversion, which writes mode: inclusive onto every edge-branched decision with two or more conditioned out-edges; a flow stored in sys_metadata is not rewritten and takes the first-match reading on upgrade (os migrate meta --stored lists those decisions). |

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

Expand Down
2 changes: 2 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,8 @@ export {
FLOW_MULTI_WRITE_UNFILTERED,
FLOW_LOOP_BODY_UNCONTAINED,
FLOW_TRY_CATCH_WITHOUT_CATCH,
FLOW_DECISION_MODE_INVALID,
FLOW_DECISION_INCLUSIVE_OVERLAP,
} from './lint-flow-patterns.js';

export { lintLivenessProperties } from './lint-liveness-properties.js';
Expand Down
Loading
Loading