Skip to content

Commit f1c0b57

Browse files
docs(skills): previous is null on insert in objectstack-data and objectstack-formula, no longer unbound (#24174)
Fixes #23983 Clause-②: no ## What changed Two published skill files told authors that `previous` is **unbound** on insert events and that insert-event conditions must therefore be written over `record` alone. Since PR #23971 (the runtime change for card 23910, merged as `b25d98a46f`) `@objectstack/objectql` binds `previous` to `null` on insert in validation rules and in declarative hook conditions — the binding a flow's start condition already had — so that text was false. Both files now state the binding and the guard: - **on insert, `previous` is `null`** in validation, hook conditions and flows: `previous == null || …` judges an insert, `previous != null && …` skips one; - **a column read off it on insert** (`previous.status`, `previous.done`) still has no value and still fails the write — guard the read with `previous != null &&`, not the event; - **a write to an existing row whose prior state is not in hand** keeps `previous` unbound and fails closed, so `previous == null` never reads an update as an insert. The old advice (write insert-event conditions over `record` alone) was still safe but is no longer the rule; it is replaced by the `null` guard, and the abort semantics for a typo'd key or a column read off `null` are kept. A hook's `ctx.previous` is unchanged and its row in the `ctx.*` table is untouched. Files (text only; nothing an author writes changes): - `skills/objectstack-data/references/data-hooks.md` — the `previous` bullet (was 「`previous` is UNBOUND where there is no prior state … insert events … write those over `record` alone」), the abort warning's example (「a `previous` reference on an insert」 → a column read off `previous` on an insert), the load-bearing clause (「the unbound-`previous` and `has(...)` bullets」 → 「the `previous`-on-insert and `has(...)` bullets」) and the authoring consequence (「never on an insert event」 → read `previous` only against `null` on insert). - `skills/objectstack-formula/SKILL.md` — the §5 scope-table insert row (was 「**unbound** — there is no prior state」), a new row for the existing-row-not-in-hand case, the abort note under the table (was 「Referencing `previous` where it is unbound … So write insert-event conditions over `record` alone」) and the legacy-translation note under `OLD.x` / `ISCHANGED(x)` (was 「exists only where `previous` is **bound** … On an insert event it is not」). - `.changeset/23983-previous-null-on-insert.md` — `'@objectstack/skills': patch`, `Clause-②: no` (the catalog ships as `packages/skills/dist`). ## `skills/**` readings (lines and tokens, `bd4276a91` → `d0c7d6f13`) Token counts are the ratchet's own (`node scripts/check-skills-token-ratchet.mjs`, `ceil(utf8 bytes / 4)`, release-stamp line excluded). | Reading | before | after | ceiling | |:---|---:|---:|---:| | `skills/objectstack-data/references/data-hooks.md` lines | 997 | 998 | — | | `skills/objectstack-data/references/data-hooks.md` tokens | 10066 (headroom 0) | 10059 (headroom 7) | 10066 | | `skills/objectstack-formula/SKILL.md` lines | 462 | 465 | — | | `skills/objectstack-formula/SKILL.md` tokens | 5399 (headroom 598) | 5511 (headroom 486) | 5997 | | all `skills/*/SKILL.md` lines (sum of 10) | 4389 | 4392 | — | | whole `skills/**` lines (65 files) | 13332 | 13336 | — | | catalog authored tokens (ratcheted) | 143907 | 144012 | 154888 | | bundle total tokens (whole shipped tree) | 155093 | 155198 | — | No ceiling moved. `data-hooks.md` sat at zero headroom, so the new sentences were paid for inside the file by deleting content, not by re-wrapping: the sentence 「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」 restated what the paragraph's first sentence (does **not** degrade … it **fails the write**) and its protocol-17 history already say, and the four-line 「put a condition that reads `previous` on an update or delete event — either phase — never on an insert event」 rule is replaced by the shorter `null`-guard rule. Net −7 tokens on that file; `objectstack-formula/SKILL.md` spends 112 of its 598 headroom on the new row and the rewritten notes. ## Verification (final head `d0c7d6f13`) - Derivation: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; change set from the merge base `bd4276a91`) → 44 families. Ran every one, exit code captured before any pipe; `--ran` reconciliation: **44 derived, 42 run (all exit 0), 2 NOT-MEASURED, 0 UNRUN**. - NOT MEASURED locally (recorded exit 3, PREREQUISITE NOT MET — they load the built `dist/` of 92 packages / `@objectstack/objectql`, which a three-text-file worktree does not build): `pnpm check:dual-build-cjs-loads`, `pnpm check:lean-entry-closure`. Both are whole-tree families named on every card; CI's `Lint & Repo Gates` builds first and runs them. - `pnpm --filter @objectstack/lint run check:doc-formula-expressions` first answered exit 3 (needs `@objectstack/formula` + `@objectstack/lint` built); after `turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` it ran and exited 0 — that is the family that reads CEL expressions out of `skills/**`. - `node scripts/check-skills-token-ratchet.mjs` exit 0: 「54 authored bundle file(s) within their ceilings」 (the readings above). - `pnpm check:role-word`, `pnpm check:corpus-claim-drift`, `pnpm check:skill-identifier-liveness`, `pnpm check:skill-compatibility`, `pnpm check:skill-frame-sync`, `pnpm check:doc-authoring`, `pnpm check:nul-bytes` and the changeset gates (`check-adr-0087-registration --base origin/main`, `check-changeset-no-major --base origin/main`, `check-empty-changeset --base origin/main`, `check:published-files`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`) all exit 0. - `pnpm --filter @objectstack/spec build` (under the shared verify lock, command-exit 0) then `pnpm --filter @objectstack/spec check:generated` → 「All 14 generated artifacts are up to date」; `check:skill-docs` ✓ and `check:skill-refs` ✓ — the `SKILL.md` change is body-only, so nothing was regenerated and no generated artifact is in this PR. - Ship-proof: `pnpm --filter @objectstack/skills build` → 「65 file(s) across 10 skill(s) copied from skills/** into dist/skills and read back byte-identical」; `grep -rc "UNBOUND where there is no prior state" packages/skills/dist/` → 0 in every file; the new bullet is at `packages/skills/dist/skills/objectstack-data/references/data-hooks.md:247` and the new table row at `packages/skills/dist/skills/objectstack-formula/SKILL.md:307`. - Control-character scan of the three changed files: none. - Premise re-checked on `origin/main` before editing: `packages/objectql/src/validation/rule-validator.ts` `ruleScope()` binds `extra.previous = null` when `previous === null`, and `hook-wrappers.ts` evaluates a declarative hook `condition` in that same scope (「`previous` is `null` on an insert event」); `content/docs/data-modeling/formulas.mdx` already states the `null`-on-insert row. ## Acceptance notes - Out of scope by the dispatch order (⛔ no other `skills/**` file): `skills/objectstack-formula/evals/cel-predicates-formulas.json` line 27, eval case 3's `expected_output`, still says 「Not usable on insert events (unbound `previous` aborts the write)」 — the same statement this PR corrects, in a fixture that ships in the same package. Reported in the dev report for the `domain:skills` seat; the fixture's `assertions` do not pin that sentence, so no eval fails on it today. - Other `unbound` hits in `skills/**` are unrelated and still true: `objectstack-formula/SKILL.md` on a form-view `visibleWhen`'s client-side unbound root falling back to `true`; `objectstack-ai/SKILL.md` and `objectstack-query/rules/pagination.md` use 「unbounded」 (numbers, queries). - `data-hooks.md`'s `ctx.previous` row (「`undefined` on insert → use `!ctx.previous`」) is still true — only the CEL binding changed — and was left as is. - `docs/adr/0058-expression-and-predicate-surface.md` mentions `bulk_write_previous_unbound` as retired history; not published skill text, not touched. ## 维护者速读(草稿) - **改了什么**:两份对外发布的 skill 文本(`objectstack-data` 的 data-hooks 参考页、`objectstack-formula` 的 SKILL.md)里「insert 时 `previous` 未绑定,只能用 `record` 写条件」的说法,改为运行时的真实行为:insert 时 `previous` 为 `null`,`previous == null` 判定 insert、`previous != null && …` 跳过 insert;insert 时读 `previous.x` 仍然报错拒写,要用 `previous != null &&` 守住;只有「已存在的行但没拿到旧状态」才是未绑定。另附一份 `@objectstack/skills` patch changeset。 - **为什么改**:PR #23971 已把运行时改成 insert 时绑 `null`,这两处 skill 文本随之变假;按旧文本写的 AI 会绕开一个已不存在的错误,也写不出「只在 insert 时必填」这类规则。 - **风险与代价(含回滚)**:纯文本,不改任何作者可写的键,也不改 hook 的 `ctx.previous`;token 棘轮 data-hooks 10066→10059(上限 10066 不动,靠删一句重复的解释句付账),formula SKILL.md 5399→5511(上限 5997)。回滚 = revert 这一个 commit。 - **席位意见**: - **你要做的**:在此 PR 上给一个 APPROVED review;席位负责落地。 --- _Generated by [Claude Code](https://claude.ai/code/session_012PxNHU3dfov2hbpg9ycZ8q)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b0fe9fa commit f1c0b57

4 files changed

Lines changed: 47 additions & 34 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/skills': patch
3+
---
4+
5+
docs(skills): `objectstack-data` and `objectstack-formula` state that `previous` is `null` on insert — `previous == null` judges an insert, a column read off it there faults, and only a write to an existing row whose prior state is not in hand leaves it unbound (#23983)
6+
7+
Clause-②: no
8+
9+
`skills/objectstack-data/references/data-hooks.md` and `skills/objectstack-formula/SKILL.md` said `previous` is unbound on insert events and told authors to write insert-event conditions over `record` alone. Since `@objectstack/objectql` binds `previous` to `null` on insert in validation rules and declarative hook conditions (the binding a flow's start condition already had), that text was false: `previous == null || …` judges an insert and `previous != null && …` skips one; only a column read off `previous` on insert (`previous.status`) still has no value and still fails the write, and only a write to an existing row whose prior state is not in hand leaves `previous` unbound, failing closed. The bullet, the scope-table row, the abort note and the legacy-translation note now state that binding and the `null` guard; the `record`-alone advice stays correct and is no longer presented as the rule. Text only; nothing an author writes changes, and a hook's `ctx.previous` is unchanged.

‎skills/objectstack-data/references/data-hooks.md‎

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -244,15 +244,18 @@ in neither). So:
244244
`previous.done != true && record.done == true`. `previous` is the stored
245245
pre-write row, made total over the same declared fields, and it is the same
246246
binding a validation predicate reads.
247-
- **`previous` is UNBOUND where there is no prior state**, and a reference to an
248-
unbound root makes the whole condition unevaluable. One case: insert events
249-
(`beforeInsert` / `afterInsert`) — write those over `record` alone. The
250-
`before*` dispatch of a predicate (`multi: true`) write is **not** a second
251-
case: `beforeUpdate` / `beforeDelete` dispatch once per matched row, each on a
252-
single-record-shaped context whose `previous` is that row's pre-image and whose
253-
`input.id` names the row (ADR-0058 Addendum II, D1/D2), so a transition
254-
condition evaluates there exactly as on a single-record write, and `record` is
255-
that row's state, stored ⊕ payload. Only the *payload* stays batch-scoped (D3):
247+
- **`previous` is `null` on insert** (`beforeInsert` / `afterInsert`), so
248+
`previous == null || …` judges an insert and `previous != null && …` skips
249+
one; a column read off it there (`previous.done`) has no value and faults.
250+
`previous` is UNBOUND only for a write to an existing row whose prior state
251+
is not in hand, which fails closed rather than reading an update as an
252+
insert. The `before*` dispatch of a predicate (`multi: true`) write is
253+
**not** such a case: `beforeUpdate` / `beforeDelete` dispatch once per
254+
matched row, each on a single-record-shaped context whose `previous` is that
255+
row's pre-image and whose `input.id` names the row (ADR-0058 Addendum II,
256+
D1/D2), so a transition condition evaluates there exactly as on a
257+
single-record write, and `record` is that row's state, stored ⊕ payload. Only
258+
the *payload* stays batch-scoped (D3):
256259
every row's dispatch carries the one payload, so a rewrite made on any row
257260
applies to every matched row, and a rewrite *decided* per row must assign the
258261
same key set on every row, in place (`ctx.input.x = …`, never a replaced
@@ -274,20 +277,18 @@ in neither). So:
274277
question about your spelling, not about your data.
275278

276279
⚠️ **An unevaluable condition ABORTS the operation.** A typo'd key
277-
(`record.stauts`), a `previous` reference on an insert, or a comparison CEL has
278-
no overload for does **not** degrade to "the hook did not fire" — it **fails the
279-
write**. Until protocol 17 the gate emitted a `logger.warn` and returned `false`,
280-
which is why the unbound-`previous` and `has(...)` bullets above are load-bearing
281-
rather than stylistic: a `before*` guard swallowed into `false` silently let
282-
writes through, and an audit hook swallowed into `false` silently dropped
283-
records. Those are opposite failures, so "the condition said no" and "the
284-
platform could not work out what the condition says" are now different outcomes
285-
and the second one is loud.
280+
(`record.stauts`), a column read off `previous` on an insert, or a comparison
281+
CEL has no overload for does **not** degrade to "the hook did not fire" — it
282+
**fails the write**. Until protocol 17 the gate emitted a `logger.warn` and
283+
returned `false`, which is why the `previous`-on-insert and `has(...)` bullets
284+
above are load-bearing rather than stylistic: a `before*` guard swallowed into
285+
`false` silently let writes through, and an audit hook swallowed into `false`
286+
silently dropped records.
286287

287288
Practical consequence when authoring: spell keys against the object's **declared**
288-
fields, and put a condition that reads `previous` on an update or delete event —
289-
either phase — never on an insert event. That mistake used to cost you a hook
290-
that quietly never ran, and now costs you every write the hook is attached to.
289+
fields, and on an insert event read `previous` only against `null` — a column
290+
read off it there is the mistake that used to cost you a hook that quietly never
291+
ran, and now costs you every write the hook is attached to.
291292

292293
#### `onError` — Error Handling
293294

‎skills/objectstack-formula/SKILL.md‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -303,19 +303,21 @@ roots — one scope, one meaning, whichever surface reads it.
303303
| Surface / event | `previous` |
304304
|:---|:---|
305305
| Update hook `condition` (single-record write), validation rule on update | the stored pre-write row |
306-
| Insert events (`beforeInsert` / `afterInsert`), validation rule on insert | **unbound** — there is no prior state |
306+
| Insert events (`beforeInsert` / `afterInsert`), validation rule on insert | **`null`** — `previous == null \|\| …` judges an insert, `previous != null && …` skips one; a column read off it (`previous.x`) has no value and faults |
307+
| A write to an existing row whose prior state is not in hand | **unbound** — fails closed, so `previous == null` never reads an update as an insert |
307308
| **`after*` hook `condition` / record-change flow trigger on a predicate (`multi: true`) write** | **that row's pre-write row** — a bulk write fires after-hooks once PER MATCHED ROW |
308309
| Validation rule on a predicate bulk update | that row's pre-write row — per row |
309310
| `before*` hook `condition` on a predicate (`multi: true`) write | **that row's pre-write row** — `beforeUpdate` / `beforeDelete` dispatch once PER MATCHED ROW too, each on a single-record-shaped context whose `input.id` names the row (ADR-0058 Addendum II, D1/D2); `record` is that row's state, stored ⊕ payload, as on any update. Only the *payload* stays batch-scoped — see below the table |
310311

311-
⚠️ **An unevaluable condition ABORTS the operation.** Referencing
312-
`previous` where it is unbound — like a typo'd key (`record.stauts`), a retired
313-
field, or a comparison CEL has no overload for — does **not** degrade to "the
314-
hook did not fire": it **fails the write**, with an error naming the hook and
315-
the key — `before*` and `after*` alike, with no `onError` escape (`onError`
316-
governs a handler that throws, and the condition is evaluated before any handler
317-
runs). A condition that does not even **compile** aborts the same way. So write
318-
insert-event conditions over `record` alone.
312+
⚠️ **An unevaluable condition ABORTS the operation.** A column read off
313+
`previous` on an insert (`previous.status`, where `previous` is `null`) — like
314+
a typo'd key (`record.stauts`), a retired field, or a comparison CEL has no
315+
overload for — does **not** degrade to "the hook did not fire": it **fails the
316+
write**, with an error naming the hook and the key — `before*` and `after*`
317+
alike, with no `onError` escape (`onError` governs a handler that throws, and
318+
the condition is evaluated before any handler runs). A condition that does not
319+
even **compile** aborts the same way. So on an insert event read `previous`
320+
only against `null`, and put a column read off it behind `previous != null &&`.
319321

320322
**A transition condition needs no special handling for bulk writes.**
321323
Write it once, on a `before*` or an `after*` event, and it means the same thing
@@ -371,9 +373,10 @@ When migrating Salesforce-flavor metadata, apply these rules in order:
371373
| `ISCHANGED(x)` | `previous.x != record.x` |
372374
| `MONTH_DIFF`, `MID`, `LEFT`, `RIGHT`, `SUBSTITUTE` | _not in stdlib — propose addition_ |
373375

374-
> ⚠️ `OLD.x` and `ISCHANGED(x)` both land on `previous.x`, which exists only
375-
> where `previous` is **bound** — see §5. On an insert event it is not; that
376-
> does not quietly skip the hook, it **fails the write**. On update and delete
376+
> ⚠️ `OLD.x` and `ISCHANGED(x)` both land on `previous.x`, which has a value
377+
> only where `previous` is a **row** — see §5. On an insert event `previous` is
378+
> `null`, so the read does not quietly skip the hook, it **fails the write**
379+
> unless guarded by `previous != null &&`. On update and delete
377380
> events it IS bound, per matched row, in `before*` and `after*` alike, on bulk
378381
> and single-record writes.
379382

‎skills/objectstack-formula/evals/cel-predicates-formulas.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
{
2525
"id": 3,
2626
"prompt": "Run an automation only when a support case has just become `escalated` — on single updates and on bulk updates alike.",
27-
"expected_output": "Writes the transition once as `P`previous.status != 'escalated' && record.status == 'escalated'``: `record` is the record's post-write state (true on every update of an already-escalated case), so only a `previous` comparison says \"just became\"; `previous` is bound per matched row on `before*` and `after*` update events, bulk included. Not usable on insert events (unbound `previous` aborts the write). There is no `ISCHANGED()` function.",
27+
"expected_output": "Writes it once as `P`previous.status != 'escalated' && record.status == 'escalated'``: `record` is the post-write state (true on every later update too), so only `previous` says \"just became\"; `previous` is bound per row on `before*`/`after*` update events, bulk too. On insert `previous` is `null`: the `previous.status` read faults and aborts the write, so to run on inserts guard with `previous != null &&`. No `ISCHANGED()` function.",
2828
"files": [],
2929
"assertions": {
3030
"must_contain": ["previous.status", "record.status", "!="],

0 commit comments

Comments
 (0)