Skip to content

Commit c5f16e3

Browse files
docs(skills): formula §5 binding-scope table says an unevaluable condition fails the write (#4814) (#4906)
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`. Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent b29ead6 commit c5f16e3

2 files changed

Lines changed: 76 additions & 5 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
---
3+
4+
Docs-only: the `objectstack-formula` skill's `previous` binding-scope table
5+
never said what an unevaluable hook `condition` now costs (#4814).
6+
7+
§5 ("Update hook condition — `previous` vs `record`") is where an AI author is
8+
taught to write `previous.x != record.x`, and the mechanical translation table
9+
sends `OLD.x` / `ISCHANGED(x)` to the same place. The table listed exactly where
10+
`previous` is **unbound** — insert events, and `multi: true` predicate bulk
11+
updates — and then closed with "referencing `previous` where it is unbound makes
12+
the whole expression unevaluable", which was the pre-17 outcome: a `logger.warn`
13+
and a hook that did not fire.
14+
15+
#4775 changed that outcome: an unevaluable condition **aborts the operation**,
16+
`before*` and `after*` in the same direction, with an error naming the hook and
17+
the key. So the table's own rows changed meaning — "this quietly disables your
18+
hook" became "this fails your write" — without a word of the table changing.
19+
That is the drift this fixes: the surface teaching the idiom was the one surface
20+
still describing the old consequence.
21+
22+
Adds, in §5:
23+
24+
- the #4775 rule, with the reason the two outcomes had to split (a `before*`
25+
guard swallowed into `false` let writes through; an audit hook swallowed into
26+
`false` dropped records — opposite failures out of one collapsed result), and
27+
the note that `onError` is not an escape from it (it governs a handler that
28+
throws; the condition is evaluated before any handler runs);
29+
- the `multi: true` cell in full (#4800/B1): one hook condition reading
30+
`previous.*` fails *every* predicate bulk update of that object, fail-loud
31+
takes no exception, and the error is a diagnosis — it names the batch, says
32+
the N matched rows have no single prior record, and gives the two real ways
33+
out (drop `previous`, or write by id). A record-change flow trigger is
34+
explicitly **not** one of them: it binds the same lifecycle hook and gets the
35+
same unbound `previous`. `record` is the bare payload on that path too, so a
36+
declared field this write does not set is unevaluable as well.
37+
38+
Plus a pointer under the legacy → CEL table, since `OLD.x` / `ISCHANGED(x)` are
39+
how a migrating author arrives at `previous.x` in the first place.
40+
41+
Releases nothing.

skills/objectstack-formula/SKILL.md

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -308,11 +308,36 @@ roots (#4784) — one scope, one meaning, whichever surface reads it.
308308
|:---|:---|
309309
| Update hook `condition` (single-record write), validation rule on update | the stored pre-write row |
310310
| Insert events (`beforeInsert` / `afterInsert`), validation rule on insert | **unbound** — there is no prior state |
311-
| 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 |
312-
313-
Referencing `previous` where it is unbound makes the whole expression
314-
unevaluable — so write insert-event conditions over `record` alone, and keep
315-
transition conditions to single-record writes.
311+
| 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 |
312+
313+
⚠️ **An unevaluable condition ABORTS the operation (#4775).** Referencing
314+
`previous` where it is unbound — like a typo'd key (`record.stauts`), a retired
315+
field, or a comparison CEL has no overload for — does **not** degrade to "the
316+
hook did not fire": it **fails the write**, with an error naming the hook and
317+
the key. Until protocol 17 the gate emitted a `logger.warn` and returned
318+
`false`, which is what makes this table load-bearing rather than stylistic: a
319+
`before*` guard swallowed into `false` silently let writes through, and an audit
320+
hook swallowed into `false` silently dropped records. Those are opposite
321+
failures, so "the condition said no" and "the platform could not work out what
322+
the condition says" are now different outcomes and the second one is loud —
323+
`before*` and `after*` in the same direction, with no `onError` escape
324+
(`onError` governs a handler that throws, and the condition is evaluated before
325+
any handler runs). A condition that does not even **compile** aborts the same
326+
way.
327+
328+
So write insert-event conditions over `record` alone, and keep transition
329+
conditions to single-record writes — that mistake used to cost you a hook that
330+
quietly never ran, and now costs you every write the hook is attached to.
331+
332+
**On a `multi: true` bulk update the cost lands on every batch (#4800/B1).**
333+
One hook condition reading `previous.*` makes *every* predicate bulk update of
334+
that object fail, and the failure names a hook that has nothing to do with the
335+
write. Fail-loud takes no exception here, but the error is a diagnosis rather
336+
than a raw `No such key: previous`: it says this is a predicate bulk write, that
337+
the N matched rows have no single prior record to bind, and gives the two ways
338+
out — rewrite the condition without `previous`, or target the write at one
339+
record (update by id). A record-change flow trigger is **not** a way around it:
340+
it binds the same lifecycle hook and receives the same unbound `previous`.
316341

317342
**`previous` is total over the object's declared fields.** A declared column the
318343
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:
349374
| `ISCHANGED(x)` | `previous.x != record.x` |
350375
| `MONTH_DIFF`, `MID`, `LEFT`, `RIGHT`, `SUBSTITUTE` | _not in stdlib — propose addition_ |
351376

377+
> ⚠️ `OLD.x` and `ISCHANGED(x)` both land on `previous.x`, which exists only
378+
> where `previous` is **bound** — see §5. On an insert event, or on a
379+
> `multi: true` predicate bulk update, it is not; since #4775 that does not
380+
> quietly skip the hook, it **fails the write**.
381+
352382
---
353383

354384
## Surfaces that take an Expression

0 commit comments

Comments
 (0)