diff --git a/.changeset/29482-formula-skill-flow-null-guards.md b/.changeset/29482-formula-skill-flow-null-guards.md new file mode 100644 index 00000000000..5f98969fbe1 --- /dev/null +++ b/.changeset/29482-formula-skill-flow-null-guards.md @@ -0,0 +1,10 @@ +--- +'@objectstack/skills': patch +--- + +docs(skills): the formula skill teaches the null-guard gate on a record-triggered flow's conditions + +Clause-②: no +Metadata-protocol: none + +The publish-time null-guard gate (`os validate` / `os build` / `os lint` and the runtime publish gate) has judged a record-triggered flow's start `config.condition`, every edge `condition` and every `decision` branch `expression` since the lint change that widened it; the formula skill now says so where it teaches the gate: which slots are judged, that a `count` / `sum` roll-up is always valued while `min` / `max` / `avg` stay nullable, that a bare field name in a flow condition is the field and is judged alike, that a guard in the start condition holds at every edge and branch and is spelled `record. != null` so both doors accept it, and that an unguarded null fails the run while the write that fired it still lands. One eval exercises a refused flow edge. The flows docs page carries the same rule where edge and decision conditions are taught. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index d350f545fd2..82691bcd986 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -1810,7 +1810,7 @@ Edges connect nodes and define the execution path: | `source` | `string` | ✅ | Source node ID | | `target` | `string` | ✅ | Target node ID | | `type` | `enum` | optional | `'default'` (success), `'fault'` (error), `'conditional'` (expression-guarded), or `'back'` (declared back-edge, ADR-0044); defaults to `'default'` | -| `condition` | `string` | optional | Boolean CEL predicate for branching (a bare string is stored as `{ dialect: 'cel', source }`) | +| `condition` | `string` | optional | Boolean CEL predicate for branching (a bare string is stored as `{ dialect: 'cel', source }`); on a record-triggered flow a nullable field needs its `!= null` guard — see [Expressions in flows](#expressions-in-flows) | | `label` | `string` | optional | Label displayed on the connector — cosmetic only. It does **not** select a path except on a branching node (`decision` / `approval`), which picks its out-edge by label. | | `isDefault` | `boolean` | optional | BPMN default flow — the **"otherwise" branch**. Traversed only when no sibling `condition` on the same source node matched; never part of the unconditional parallel fan-out. Mutually exclusive with `condition`, at most one per node | @@ -2443,6 +2443,18 @@ and is read as `vars["current_user"]` / `vars["vars"]`. 4. **A single-brace token in a text slot** — `title: 'Deal won: {record.name}'` — is refused with its `{{ }}` spelling ([text slots](#text-slots-read-double-brace-holes)): it is no placeholder there, and would go out as literal text. +5. **An ordering or arithmetic operator on a nullable field, unguarded** — on a + record-triggered flow (`triggerType: 'record-*'`), `os validate` and the + runtime publish gate refuse a start `condition`, an edge `condition` (nested + regions too) or a `decision` branch `expression` that applies `< <= > >=` or + `+ - * / %` to a field of the trigger object with no `required: true`, + `defaultValue` or default option (a `count` / `sum` roll-up reads `0` when + empty; `min` / `max` / `avg` stay nullable), unguarded by `!= null` / + `== null` / `!isBlank()` in the same branch: the run binds every declared + field, `null` when empty, CEL has no `>` for null, and the run fails there + while the write that fired it still lands. A bare `budget` is `record.budget`, + and `os validate` judges it too; a start guard holds at every edge and branch — spell + it `record.budget != null`, which both doors accept, and `has()` is no guard. diff --git a/skills/objectstack-formula/SKILL.md b/skills/objectstack-formula/SKILL.md index 9f66e280d2d..60e2c4b926e 100644 --- a/skills/objectstack-formula/SKILL.md +++ b/skills/objectstack-formula/SKILL.md @@ -114,9 +114,12 @@ record.start_date != null && record.end_date != null && record.end_date < record **This is a publish-time rejection, not advice.** `os build` / `os validate` / `os lint` and the runtime publish gate reject any validation-rule -or hook predicate that applies an ordering (`< <= > >=`) or arithmetic +or hook predicate — and a record-triggered flow's (`triggerType: 'record-*'`) +start `config.condition`, edge `condition` (nested regions too) or `decision` +branch `expression` — that applies an ordering (`< <= > >=`) or arithmetic (`+ - * / %`) operator to a **declared nullable** field — no `required: true`, -no `defaultValue`, no default option — unless an explicit `!= null` / `== null` / +no `defaultValue`, no default option, not a `count` / `sum` roll-up (empty reads +`0`; `min` / `max` / `avg` stay nullable) — unless an explicit `!= null` / `== null` / `!isBlank()` test dominates it in the same boolean branch. `has()` deliberately does not satisfy that gate. `has()` over an **undeclared** key stays legal: that is its use there — telling "absent from this PATCH" apart from "explicitly null". @@ -137,12 +140,6 @@ has(record.amount) && record.amount != null && record.amount > 1000 has(record.viewer) && has(record.viewer.can_approve) && record.viewer.can_approve == true ``` -### Null + string throws - -CEL has no implicit `null` coercion. `null + 'foo'` throws -`no such overload: dyn + string`. Wrap every nullable string operand -in `coalesce(..., '')`. - --- ## ObjectStack CEL standard library @@ -339,9 +336,7 @@ a typo'd key (`record.stauts`), a retired field, or a comparison CEL has no overload for — does **not** degrade to "the hook did not fire": it **fails the write**, with an error naming the hook and the key — `before*` and `after*` alike, with no `onError` escape (`onError` governs a handler that throws, and -the condition is evaluated before any handler runs). A condition that does not -even **compile** aborts the same way. So on an insert event read `previous` -only against `null`, and put a column read off it behind `previous != null &&`. +the condition is evaluated before any handler runs). **A transition condition needs no special handling for bulk writes.** Write it once, on a `before*` or an `after*` event, and it means the same thing @@ -397,17 +392,19 @@ When migrating Salesforce-flavor metadata, apply these rules in order: | `ISCHANGED(x)` | `previous.x != record.x` | | `MONTH_DIFF`, `MID`, `LEFT`, `RIGHT`, `SUBSTITUTE` | _not in stdlib — propose addition_ | -> ⚠️ `OLD.x` and `ISCHANGED(x)` both land on `previous.x`, which has a value -> only where `previous` is a **row** — see §5. On an insert event `previous` is -> `null`, so the read does not quietly skip the hook, it **fails the write** -> unless guarded by `previous != null &&`. On update and delete -> events it IS bound, per matched row, in `before*` and `after*` alike, on bulk -> and single-record writes. +> ⚠️ `OLD.x` and `ISCHANGED(x)` both land on `previous.x`, which is `null` on an +> insert event and **fails the write** there unless guarded by `previous != null +> &&`; on update and delete events it is bound per matched row — see §5. -> ⚠️ **Flow conditions are the exception to row 1.** The automation engine -> spreads the record's variables to top level, so a bare `status` resolves in a -> flow start condition. `record.status` resolves there too and is the only -> spelling valid on every other surface — prefer it. +> ⚠️ **Flow conditions are the exception to row 1.** The engine spreads the +> trigger record's fields, nulls included, to top level wherever no flow variable +> or node id holds the name, so in a start, edge or `decision` condition a bare +> `budget` IS `record.budget`: `os validate` judges either spelling (bare names +> only while the flow's bound names are provable) and a guard on one guards both. +> What the start condition proves non-null is presumed at every edge and branch; +> spell that guard `record.budget != null` — the runtime publish gate judges the +> written flow alone and resolves no bare name. An unguarded null fails the +> **run** at that condition; the write that fired it still lands. --- @@ -471,8 +468,7 @@ data instead, `{{ amount | currency:@currency_code }}`. ## Verify your work -A malformed expression does not fail silently (ADR-0032). It used to evaluate to -`null` / `false` — a flow "fired" and did nothing; now `os validate` and +A malformed expression does not fail silently (ADR-0032): `os validate` and `os build` run the shared validator over every formula and predicate in the stack — CEL syntax **plus** `record.` existence on the target object — and fail non-zero with a located, did-you-mean message, while at runtime the diff --git a/skills/objectstack-formula/evals/cel-predicates-formulas.json b/skills/objectstack-formula/evals/cel-predicates-formulas.json index 23b83138836..7b0b73a54c4 100644 --- a/skills/objectstack-formula/evals/cel-predicates-formulas.json +++ b/skills/objectstack-formula/evals/cel-predicates-formulas.json @@ -80,6 +80,16 @@ "must_contain": ["currency:@"], "must_not_contain": ["currency:currency_code", "currency:USD", "currency:CNY", "| currency }}"] } + }, + { + "id": 9, + "prompt": "A record-change flow on `project` (start `triggerType: 'record-after-update'`, `objectName: 'project'`) escalates when the budget passes 200000. `budget` is a currency field with no `required` and no `defaultValue`. Write the edge condition from the decision node to the escalation branch.", + "expected_output": "`record.budget != null && record.budget > 200000`. The run binds every declared field of the trigger record, `null` when empty, and CEL has no `>` overload for null, so `os validate` and the runtime publish gate refuse the unguarded edge; at runtime the run would fail at that edge while the write that fired it still lands. A bare `budget > 200000` is the same field, and `os validate` refuses it too; and `has(record.budget)` is not a guard: a declared field holding null is still present.", + "files": [], + "assertions": { + "must_contain": ["record.budget != null", "record.budget > 200000"], + "must_not_contain": ["has(record.budget) && record.budget >", "{record."] + } } ] }