|
1 | 1 | # @objectstack/example-crm |
2 | 2 |
|
| 3 | +## 4.0.92-rc.2 |
| 4 | + |
| 5 | +### Patch Changes |
| 6 | + |
| 7 | +- 5293114: fix(automation): a decision's three declared ways to route a branch are now one working model (#4414) |
| 8 | + |
| 9 | + A `decision` node advertised three mechanisms for splitting a path and only one |
| 10 | + of them did anything. The other two were the ADR-0049 `declared ≠ enforced` |
| 11 | + shape, and the pair of them shipped a guard that does not guard in |
| 12 | + `examples/app-crm`. |
| 13 | + |
| 14 | + | mechanism | before | now | |
| 15 | + | :--------------------------------------------------- | :----------------------------------------------------------------------------------------------------- | :---------------------------------------------------- | |
| 16 | + | `edge.condition` | ✅ the only one that worked | unchanged | |
| 17 | + | `edge.isDefault` | **zero readers** anywhere but the schema declaration | BPMN default flow, enforced in `traverseNext` | |
| 18 | + | `decision.config.conditions[].label` → `branchLabel` | matched **0** out-edge labels across every example app, then fell back to the full edge set in silence | routes; an unclaimable label is logged, not swallowed | |
| 19 | + |
| 20 | + ## What was broken, end to end |
| 21 | + |
| 22 | + `crm_convert_lead_wizard` means "already converted → abort screen; otherwise → |
| 23 | + the wizard". It ran **both**: an already-converted lead got |
| 24 | + "This lead has already been converted" and then walked straight into the |
| 25 | + conversion wizard behind it. Four independent silences stacked up: |
| 26 | + |
| 27 | + 1. the decision's first condition was authored `{lead_record.status} == |
| 28 | +'converted'` — braces in a slot declared bare CEL, so it was string-compared |
| 29 | + and never true; |
| 30 | + 2. the second (`'true'`) therefore won, yielding `branchLabel: 'No — proceed'`; |
| 31 | + 3. no out-edge carried that label (they were `'Yes'` / `'No'`), so traversal |
| 32 | + discarded the branch and considered every out-edge; |
| 33 | + 4. `e3b` was unconditional, so it ran regardless — and the natural fix, marking |
| 34 | + it `isDefault: true`, was a dead key. |
| 35 | + |
| 36 | + ## The model |
| 37 | + |
| 38 | + `branchLabel` narrows the edge set → `condition` gates each edge → `isDefault` |
| 39 | + catches whatever is left. Concretely: |
| 40 | + |
| 41 | + - **`isDefault` is enforced.** A default edge is traversed only when no |
| 42 | + conditional sibling of the same source node matched, and it is no longer part |
| 43 | + of the unconditional parallel fan-out — that distinction is the whole point of |
| 44 | + the marker. Passed over because a real branch won, its target records the same |
| 45 | + `skipped` step a closed gate does (#4354). |
| 46 | + - **An unclaimable branch label warns.** Traversal still falls back to the full |
| 47 | + edge set (a run mid-flight must not die on a metadata error) but says so, |
| 48 | + naming the computed branch and the out-edge labels that exist. |
| 49 | + - **A decision that declares no `conditions` reports no branch.** It used to |
| 50 | + report `'default'` unconditionally — a label no out-edge in the repo ever |
| 51 | + carried — which is why every decision node fell back to the full edge set. |
| 52 | + The `'default'` sentinel survives for the case it actually describes (declared |
| 53 | + conditions, none matched) and is now claimed by the `isDefault` edge as well |
| 54 | + as by an edge literally labelled `'default'`. |
| 55 | + - **`conditions[].expression` is evaluated as the bare CEL it is declared to |
| 56 | + be.** The raw string went to the legacy `{var}` template path, where |
| 57 | + `lead.status == 'converted'` cannot resolve and the branch is decided by |
| 58 | + string comparison. Unlike `edge.condition` this slot carries no |
| 59 | + `ExpressionInput` envelope — the decision descriptor is deliberately |
| 60 | + schemaless — so the executor supplies the dialect. A brace-in-CEL predicate |
| 61 | + now fails loudly (ADR-0032 §1c) instead of deciding `false`. |
| 62 | + |
| 63 | + ## Caught at authoring time too |
| 64 | + |
| 65 | + Four new `os build` / `os validate` warnings, because a wrong route is silent at |
| 66 | + run time by nature (Prime Directive #12): |
| 67 | + |
| 68 | + `flow-branch-label-unmatched` (the shipped shape), |
| 69 | + `flow-decision-unconditional-branch` (a guarded decision with an unconditional |
| 70 | + sibling — the actual hole), `flow-default-edge-with-condition` and |
| 71 | + `flow-multiple-default-edges`. |
| 72 | + |
| 73 | + Both of the first two fire on the pre-fix `convert-lead.flow.ts` and are silent |
| 74 | + after it. |
| 75 | + |
| 76 | + ## The example app |
| 77 | + |
| 78 | + `crm_convert_lead_wizard`'s guard is now a plain exclusive gateway: the |
| 79 | + redundant `config.conditions` is gone and `e3b` carries `isDefault: true`. One |
| 80 | + mechanism per decision, and exactly one branch runs. |
| 81 | + |
| 82 | + Verified: 11 new engine/executor tests (including the reported repro in both |
| 83 | + directions), 12 new linter tests; `@objectstack/service-automation` 577 tests |
| 84 | + and `@objectstack/cli` 652 tests green, all three example apps build with no new |
| 85 | + findings. |
| 86 | + |
| 87 | +- Updated dependencies [462d9c4] |
| 88 | +- Updated dependencies [5b843fb] |
| 89 | +- Updated dependencies [5293114] |
| 90 | +- Updated dependencies [20bc357] |
| 91 | +- Updated dependencies [eb4204b] |
| 92 | +- Updated dependencies [04f1182] |
| 93 | +- Updated dependencies [ad5fe25] |
| 94 | + - @objectstack/spec@17.0.0-rc.2 |
| 95 | + - @objectstack/runtime@17.0.0-rc.2 |
| 96 | + |
3 | 97 | ## 4.0.92-rc.1 |
4 | 98 |
|
5 | 99 | ### Patch Changes |
|
0 commit comments