diff --git a/.changeset/skill-formula-condition-abort-scope.md b/.changeset/skill-formula-condition-abort-scope.md new file mode 100644 index 0000000000..8b285cfeb5 --- /dev/null +++ b/.changeset/skill-formula-condition-abort-scope.md @@ -0,0 +1,41 @@ +--- +--- + +Docs-only: the `objectstack-formula` skill's `previous` binding-scope table +never said what an unevaluable hook `condition` now costs (#4814). + +§5 ("Update hook condition — `previous` vs `record`") is where an AI author is +taught to write `previous.x != record.x`, and the mechanical translation table +sends `OLD.x` / `ISCHANGED(x)` to the same place. The table listed exactly where +`previous` is **unbound** — insert events, and `multi: true` predicate bulk +updates — and then closed with "referencing `previous` where it is unbound makes +the whole expression unevaluable", which was the pre-17 outcome: a `logger.warn` +and a hook that did not fire. + +#4775 changed that outcome: an unevaluable condition **aborts the operation**, +`before*` and `after*` in the same direction, with an error naming the hook and +the key. So the table's own rows changed meaning — "this quietly disables your +hook" became "this fails your write" — without a word of the table changing. +That is the drift this fixes: the surface teaching the idiom was the one surface +still describing the old consequence. + +Adds, in §5: + +- the #4775 rule, with the reason the two outcomes had to split (a `before*` + guard swallowed into `false` let writes through; an audit hook swallowed into + `false` dropped records — opposite failures out of one collapsed result), and + the note that `onError` is not an escape from it (it governs a handler that + throws; the condition is evaluated before any handler runs); +- the `multi: true` cell in full (#4800/B1): one hook condition reading + `previous.*` fails *every* predicate bulk update of that object, fail-loud + takes no exception, and the error is a diagnosis — it names the batch, says + the N matched rows have no single prior record, and gives the two real ways + out (drop `previous`, or write by id). A record-change flow trigger is + explicitly **not** one of them: it binds the same lifecycle hook and gets the + same unbound `previous`. `record` is the bare payload on that path too, so a + declared field this write does not set is unevaluable as well. + +Plus a pointer under the legacy → CEL table, since `OLD.x` / `ISCHANGED(x)` are +how a migrating author arrives at `previous.x` in the first place. + +Releases nothing. diff --git a/skills/objectstack-formula/SKILL.md b/skills/objectstack-formula/SKILL.md index 40870142f6..c28085aeb8 100644 --- a/skills/objectstack-formula/SKILL.md +++ b/skills/objectstack-formula/SKILL.md @@ -308,11 +308,36 @@ roots (#4784) — one scope, one meaning, whichever surface reads it. |:---|:---| | Update hook `condition` (single-record write), validation rule on update | the stored pre-write row | | Insert events (`beforeInsert` / `afterInsert`), validation rule on insert | **unbound** — there is no prior state | -| Predicate bulk update (`multi: true`) hook `condition` | **unbound** — one write matches N rows and the hook fires once, so there is no single prior record | - -Referencing `previous` where it is unbound makes the whole expression -unevaluable — so write insert-event conditions over `record` alone, and keep -transition conditions to single-record writes. +| Predicate bulk update (`multi: true`) hook `condition` | **unbound** — one write matches N rows and the hook fires once, so there is no single prior record. `record` is the bare payload here too, so a *declared* field this write does not set is unevaluable as well | + +⚠️ **An unevaluable condition ABORTS the operation (#4775).** Referencing +`previous` where it is unbound — like 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. Until protocol 17 the gate emitted a `logger.warn` and returned +`false`, which is what makes this table load-bearing rather than stylistic: a +`before*` guard swallowed into `false` silently let writes through, and an audit +hook swallowed into `false` silently dropped records. Those are opposite +failures, so "the condition said no" and "the platform could not work out what +the condition says" are now different outcomes and the second one is loud — +`before*` and `after*` in the same direction, 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 write insert-event conditions over `record` alone, and keep transition +conditions to single-record writes — that mistake used to cost you a hook that +quietly never ran, and now costs you every write the hook is attached to. + +**On a `multi: true` bulk update the cost lands on every batch (#4800/B1).** +One hook condition reading `previous.*` makes *every* predicate bulk update of +that object fail, and the failure names a hook that has nothing to do with the +write. Fail-loud takes no exception here, but the error is a diagnosis rather +than a raw `No such key: previous`: it says this is a predicate bulk write, that +the N matched rows have no single prior record to bind, and gives the two ways +out — rewrite the condition without `previous`, or target the write at one +record (update by id). A record-change flow trigger is **not** a way around it: +it binds the same lifecycle hook and receives the same unbound `previous`. **`previous` is total over the object's declared fields.** A declared column the driver never returned reads as `null`, not as a fault. Guard with `!= null`, @@ -349,6 +374,11 @@ 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 exists only +> where `previous` is **bound** — see §5. On an insert event, or on a +> `multi: true` predicate bulk update, it is not; since #4775 that does not +> quietly skip the hook, it **fails the write**. + --- ## Surfaces that take an Expression