From 637ef2a374a9ff4ddc47108564b98a6f97815d97 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 15:09:56 +0000 Subject: [PATCH] =?UTF-8?q?docs(skills):=20formula=20=C2=A75=20binding-sco?= =?UTF-8?q?pe=20table=20says=20an=20unevaluable=20condition=20fails=20the?= =?UTF-8?q?=20write=20(#4814)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `objectstack-formula` skill's `previous` binding-scope table listed exactly where `previous` is unbound and then closed with "makes the whole expression unevaluable" — the pre-17 outcome (warn + hook does not fire). #4775 changed that outcome to aborting the operation, so the table's rows changed meaning without a word of the table changing. Adds the #4775 rule (before*/after* same direction, error names hook and key, onError is not an escape) and the #4800/B1 cell in full: one `previous.*` condition fails every predicate bulk update of that object, with a diagnosis instead of `No such key: previous`, and a record-change flow trigger is not a way around it. Plus a pointer under the legacy → CEL table, since `OLD.x` / `ISCHANGED(x)` are how a migrating author arrives at `previous.x`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ --- .../skill-formula-condition-abort-scope.md | 41 +++++++++++++++++++ skills/objectstack-formula/SKILL.md | 40 +++++++++++++++--- 2 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 .changeset/skill-formula-condition-abort-scope.md 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