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
41 changes: 41 additions & 0 deletions .changeset/skill-formula-condition-abort-scope.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 35 additions & 5 deletions skills/objectstack-formula/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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
Expand Down
Loading