From 931f02a036f64b9dac786bd0a6272c9575bd4217 Mon Sep 17 00:00:00 2001 From: os-support-ai Date: Tue, 3 Nov 2026 06:26:27 +0000 Subject: [PATCH 1/4] docs(skills): the formula skill and the flows page teach the null-guard gate on a record-triggered flow's conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish-time null-guard gate judges a record-triggered flow's start `config.condition`, every edge `condition` (nested regions too) and every `decision` branch `expression`. The formula skill now teaches which slots are judged, the `count` / `sum` roll-up exception, the bare-name rule, the start-condition guard spelled `record. != null` so both doors accept it, and the run-fails outcome; one eval exercises a refused flow edge. The flows page carries the same rule where edge and decision conditions are taught. Paid in the same file: the `previous` callout restating §5 and the pre-ADR-0032 history clause. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfSZ55f7xwAfpfkPgY --- .../29482-formula-skill-flow-null-guards.md | 10 ++++++ content/docs/automation/flows.mdx | 14 +++++++- skills/objectstack-formula/SKILL.md | 36 +++++++++++-------- .../evals/cel-predicates-formulas.json | 10 ++++++ 4 files changed, 54 insertions(+), 16 deletions(-) create mode 100644 .changeset/29482-formula-skill-flow-null-guards.md 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..75c1a679e11 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`, + judged alike; a start-condition 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 0050e9403b4..686d3fa4f89 100644 --- a/skills/objectstack-formula/SKILL.md +++ b/skills/objectstack-formula/SKILL.md @@ -112,9 +112,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 real use — telling "absent from this PATCH" apart from "explicitly null". @@ -379,17 +382,21 @@ 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. - -> ⚠️ **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. +> ⚠️ `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 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`: the null-guard gate judges either spelling (bare +> names only while every node's bindings are declared), a guard on one spelling +> guards both, and what the start condition proves non-null is presumed at every +> edge and branch. Spell that start guard `record.budget != null`: the runtime +> publish gate judges the written flow alone, proves nothing from a bare +> `budget != null`, and refuses the edge `os validate` passed. An unguarded null +> fails the **run** at that condition; the write that fired it still lands. +> `record.x` is the only spelling valid on every other surface — prefer it. --- @@ -453,8 +460,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..259f8b6026f 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 is refused the same way, 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."] + } } ] } From 487ad8da7150edc72e3deddb70a4649dd0417d25 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 08:11:14 +0000 Subject: [PATCH 2/4] docs(skills): say which door judges a bare field name in a flow condition A bare `budget` in a record-triggered flow's condition is judged by `os validate` (where the flow's bound names are provable); the runtime publish gate judges the written flow alone and resolves no bare name. The skill callout, the flows page item and eval #9 now say `os validate` where they said "the gate" or "refused the same way", measured against the lint source at both doors. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfSZ55f7xwAfpfkPgY --- content/docs/automation/flows.mdx | 2 +- skills/objectstack-formula/SKILL.md | 2 +- skills/objectstack-formula/evals/cel-predicates-formulas.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 75c1a679e11..82691bcd986 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -2453,7 +2453,7 @@ and is read as `vars["current_user"]` / `vars["vars"]`. `== 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`, - judged alike; a start-condition guard holds at every edge and branch — spell + 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 686d3fa4f89..82c0de0ab1e 100644 --- a/skills/objectstack-formula/SKILL.md +++ b/skills/objectstack-formula/SKILL.md @@ -389,7 +389,7 @@ When migrating Salesforce-flavor metadata, apply these rules in order: > ⚠️ **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`: the null-guard gate judges either spelling (bare +> `budget` IS `record.budget`: `os validate` judges either spelling (bare > names only while every node's bindings are declared), a guard on one spelling > guards both, and what the start condition proves non-null is presumed at every > edge and branch. Spell that start guard `record.budget != null`: the runtime diff --git a/skills/objectstack-formula/evals/cel-predicates-formulas.json b/skills/objectstack-formula/evals/cel-predicates-formulas.json index 259f8b6026f..7b0b73a54c4 100644 --- a/skills/objectstack-formula/evals/cel-predicates-formulas.json +++ b/skills/objectstack-formula/evals/cel-predicates-formulas.json @@ -84,7 +84,7 @@ { "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 is refused the same way, and `has(record.budget)` is not a guard: a declared field holding null is still present.", + "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"], From d97d2e3324a00b129b0a222897fbbac4d5845a82 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 08:48:33 +0000 Subject: [PATCH 3/4] docs(skills): pay the formula skill's token ceiling back after the sparse-action paragraph landed The merged file read 6086 tokens against the 5997 ceiling. Paid by deletion in the same file: the flow-condition callout drops its restated "prefer record.x" sentence and the two-door clause now says only that the runtime publish gate resolves no bare name; the "Null + string throws" section restated pattern 1 (always coalesce; CEL throws on null + string); the ABORTS paragraph restated pattern 3's compile sentence and the previous-table's insert-leg remedy. Reads 5956 of 5997 after. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfSZ55f7xwAfpfkPgY --- skills/objectstack-formula/SKILL.md | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/skills/objectstack-formula/SKILL.md b/skills/objectstack-formula/SKILL.md index 6e1434d6dd3..9671b65a493 100644 --- a/skills/objectstack-formula/SKILL.md +++ b/skills/objectstack-formula/SKILL.md @@ -140,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 @@ -342,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 @@ -407,14 +399,12 @@ When migrating Salesforce-flavor metadata, apply these rules in order: > ⚠️ **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 every node's bindings are declared), a guard on one spelling -> guards both, and what the start condition proves non-null is presumed at every -> edge and branch. Spell that start guard `record.budget != null`: the runtime -> publish gate judges the written flow alone, proves nothing from a bare -> `budget != null`, and refuses the edge `os validate` passed. An unguarded null -> fails the **run** at that condition; the write that fired it still lands. -> `record.x` is the only spelling valid on every other surface — prefer it. +> `budget` IS `record.budget`: `os validate` judges either spelling (bare names +> only while every node's bindings are declared) 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. --- From 554bea27f6f3ed665654536aeca5d40fa8fe76fb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 09:11:37 +0000 Subject: [PATCH 4/4] docs(skills): say when os validate judges a bare flow-condition name exactly The flow-condition callout said bare names are judged "only while every node's bindings are declared"; the scope also stands down for an entrance that hands a record not in hand and for a contract-less screen, so it now says "only while the flow's bound names are provable". Same line count, same token reading. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfSZ55f7xwAfpfkPgY --- skills/objectstack-formula/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/objectstack-formula/SKILL.md b/skills/objectstack-formula/SKILL.md index 9671b65a493..60e2c4b926e 100644 --- a/skills/objectstack-formula/SKILL.md +++ b/skills/objectstack-formula/SKILL.md @@ -400,7 +400,7 @@ When migrating Salesforce-flavor metadata, apply these rules in order: > 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 every node's bindings are declared) and a guard on one guards both. +> 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