|
| 1 | +--- |
| 2 | +"@objectstack/spec": minor |
| 3 | +"@objectstack/service-automation": minor |
| 4 | +"@objectstack/lint": minor |
| 5 | +"@objectstack/metadata-protocol": minor |
| 6 | +"@objectstack/metadata-core": patch |
| 7 | +--- |
| 8 | + |
| 9 | +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) |
| 10 | + |
| 11 | +<!-- adr-0087: registered flow-decision-mode-inclusive-explicit --> |
| 12 | + |
| 13 | +Clause-②: yes |
| 14 | + |
| 15 | +**BREAKING** — the run-time semantics of a shipped node type change. A `decision` node that |
| 16 | +declares no `config.conditions` and branches on its out-edges used to take EVERY out-edge whose |
| 17 | +condition held, one after another, while its schema, the docs and the engine's own comment all |
| 18 | +called it an exclusive gateway; hotcrm#1555 rendered a refusal screen AND ran the conversion in |
| 19 | +one execution. Maintainer ruling on #15429 (2026-09-23, 「跟主流对齐」): the gateway follows |
| 20 | +BPMN's exclusive gateway, Salesforce Flow's Decision and n8n's Switch default, and taking every |
| 21 | +true branch is a declaration the author writes down. |
| 22 | + |
| 23 | +| | before | after | |
| 24 | +|:--|:--|:--| |
| 25 | +| two conditioned out-edges, both hold | both successors run, sequentially, nothing reported | the FIRST declared one runs; the second records a `skipped` step | |
| 26 | +| `config: { mode: 'inclusive' }` | accepted, never read | every out-edge whose condition holds runs, sequentially | |
| 27 | +| none holds | the `isDefault` edge runs | unchanged | |
| 28 | +| `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 | |
| 29 | + |
| 30 | +## Migration: FROM → TO |
| 31 | + |
| 32 | +`os migrate meta --from 17` lists the mechanical edits for existing sources and applies them |
| 33 | +to the migrated stack: the ADR-0087 D2 conversion `flow-decision-mode-inclusive-explicit` |
| 34 | +writes `mode: 'inclusive'` onto every decision that has no `conditions` list and two or more |
| 35 | +conditioned out-edges, inside ADR-0031 regions included, so a migrated flow runs exactly as it |
| 36 | +did. |
| 37 | + |
| 38 | +```ts |
| 39 | +// FROM — every true out-edge ran |
| 40 | +{ id: 'verdict', type: 'decision', label: 'Verdict?' } |
| 41 | +// TO — what the conversion writes; delete the key where the conditions partition |
| 42 | +{ id: 'verdict', type: 'decision', label: 'Verdict?', config: { mode: 'inclusive' } } |
| 43 | +``` |
| 44 | + |
| 45 | +Then review each written key (the paired D3 entry `flow-decision-edge-branching-first-match` |
| 46 | +carries the acceptance criteria): delete it where the conditions partition (`== 'a'` beside |
| 47 | +`!= 'a'`, `>` beside `<=`, a guard beside `isDefault: true`), keep it where the flow relies on |
| 48 | +more than one branch running for one record, and where the overlap was accidental narrow the |
| 49 | +conditions into a partition and delete the key. `os validate` reports |
| 50 | +`flow-decision-inclusive-overlap` on every decision that keeps the key with two or more |
| 51 | +conditioned out-edges, so the review list is the lint output. |
| 52 | + |
| 53 | +## BREAKING for flows stored in `sys_metadata` — maintainer ruling letter C on #15429 |
| 54 | + |
| 55 | +A `decision` node stored in `sys_metadata` (a flow built or edited in the Studio designer) with |
| 56 | +**no `config.conditions`, no `mode`, and two or more out-edges carrying a `condition`** evaluates |
| 57 | +**first-match** after this upgrade: where it took every out-edge whose condition held, it now takes |
| 58 | +only the first one that holds, in the order the flow declares its edges. Nothing rewrites that row |
| 59 | +— no stored-row migration, no cutoff, no read-path completion — because nothing about a stored row |
| 60 | +says it was saved before the flip. The one-line fix, for a node that meant every branch: |
| 61 | + |
| 62 | +```ts |
| 63 | +{ id: 'route', type: 'decision', label: 'Route', config: { mode: 'inclusive' } } |
| 64 | +``` |
| 65 | + |
| 66 | +`os migrate meta --stored` (and `POST /api/v1/meta/_migrate-stored`) lists every such node under |
| 67 | +`decisionModeReview` — flow row, node id, label and path — on a preview and an `--apply` run |
| 68 | +alike, and writes nothing for it: the list moves no row outcome, no count and no exit code, so an |
| 69 | +operator can review the candidates before and after the upgrade. A node leaves the list once it |
| 70 | +declares `mode`, either member. Every such node in the measured corpus below is a partition, where |
| 71 | +the new meaning runs exactly what the old one did. |
| 72 | + |
| 73 | +Authored sources and built artifacts keep the old behaviour instead, where the source's age is a |
| 74 | +fact: `os migrate meta --from 17` writes `mode: 'inclusive'` (above), while the authoring funnel, |
| 75 | +the automation engine's flow rehydration seam and the artifact-ingestion door all refuse the |
| 76 | +conversion by id — a default flip replayed there would turn a decision written today against this |
| 77 | +contract, where an omitted `mode` means exclusive, into an inclusive gateway. |
| 78 | + |
| 79 | +## Reach, measured at landing |
| 80 | + |
| 81 | +- Release state: the npm registry's `latest` `@objectstack/spec` is `17.4.0` (`npm view`, |
| 82 | + 2026-09-27), whose `json-schema/automation/DecisionConfig.json` declares `conditions` only — |
| 83 | + `mode` has not shipped; `.changeset/19867-decision-config-mode.md` and |
| 84 | + `.changeset/20168-decision-mode-beside-conditions-refused.md` are still unconsumed in this |
| 85 | + tree. So `mode` reaches its first release together with the traversal that reads it and the |
| 86 | + conversion that writes it; no published accept set narrows, and the registration and |
| 87 | + `os validate` refusals narrow nothing that shipped. |
| 88 | +- Corpus census (this repository at the branch base and `objectstack-ai/hotcrm` at `2f7b2326`, |
| 89 | + read-only): 30 decision nodes across 48 flows; 17 have two or more conditioned out-edges and |
| 90 | + no `mode` (the conversion's positives — every one a hand-written partition, including |
| 91 | + hotcrm's `lead_conversion.decision_duplicate`, the #1555 node), 13 have one conditioned |
| 92 | + out-edge (left alone), and no node of any other type carries a conditioned out-edge, so the |
| 93 | + exclusive traversal is scoped to `decision` with nothing else to migrate. |
| 94 | +- What the published surface gains: the D2 conversion and its D3 entry in the protocol-18 |
| 95 | + chain (`spec-changes.json`, the upgrade guide), `DecisionConfigSchema.mode`'s describe and |
| 96 | + docblock now state the run-time semantics, and `@objectstack/lint` gains |
| 97 | + `flow-decision-mode-invalid` (gating) and `flow-decision-inclusive-overlap` (advisory). |
| 98 | + |
| 99 | +The traversal change is scoped to `decision` nodes: conditioned out-edges of any other node |
| 100 | +type keep the every-true-edge traversal they had (none was measured to exist). |
0 commit comments