Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions .changeset/29482-formula-skill-flow-null-guards.md
Original file line number Diff line number Diff line change
@@ -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.<field> != 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.
14 changes: 13 additions & 1 deletion content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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.
</Callout>

<Callout type="info">
Expand Down
42 changes: 19 additions & 23 deletions skills/objectstack-formula/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand All @@ -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<null> + string`. Wrap every nullable string operand
in `coalesce(..., '')`.

---

## ObjectStack CEL standard library
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

---

Expand Down Expand Up @@ -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.<field>` existence on the target object —
and fail non-zero with a located, did-you-mean message, while at runtime the
Expand Down
10 changes: 10 additions & 0 deletions skills/objectstack-formula/evals/cel-predicates-formulas.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."]
}
}
]
}
Loading