Skip to content

Commit b25d98a

Browse files
fix(objectql): bind previous to null on insert so a rule that reads it answers with its own message (#23971)
Fixes #23910 Clause-②: yes (widening) ## What changed On insert, `previous` is now bound to `null` in validation rules and in declarative hook conditions. So a rule or condition that reads it decides the insert. The flow engine already gives a start condition on its create leg the same binding. Before, the insert path left `previous` unbound. Any reference to it, including the guard `previous == null`, faulted `Unknown variable: previous`. A violating insert was then refused (validation) or aborted (hook) as `unevaluable`, naming the CEL fault instead of the rule's message. `sys_user_permission_set.validity_window_order` is the measured case. **This widens the published accept set**, hence `Clause-②: yes (widening)` and a `minor` changeset: - A rule shaped `previous != null && …` ("judge updates only") now ADMITS inserts that it refused before with the `unevaluable` fault. - A field's `requiredWhen: previous == null` now requires the field on insert, instead of refusing every insert. No shipped rule's or hook's verdict moves (census below). ### Validation (`packages/objectql/src/validation/rule-validator.ts`) - **`previousBinding(mode, priorRecord, fields)`** decides the binding once per evaluation. On insert it returns `null`. On an update with the prior row in hand, it returns that row made total over the declared fields, as before. - **An update whose prior row is not in hand stays unbound.** Examples are the bulk seam's `previous: null` and an update-mode preview that names no row. `null` there would answer `previous == null` with "this is an insert", which is false. Unbound, a rule that reads `previous` fails closed. - **`ruleScope(record, previous, extra)`** builds the scope for every rule-evaluation `ExpressionEngine.evaluate` in the file. That covers: - a `script` / `cross_field` condition; - a `conditional`'s `when`; - a field's `requiredWhen` and its ADR-0113 pre-check; - an option's `visibleWhen`; - `readonlyWhen`, which is update-only and unchanged. The `previous ?? undefined` that turned an explicit `null` back into "unbound" is gone. `null` rides `extra`, the bag `buildScope` merges onto the variable scope, because `EvalContext.previous` is typed for a row only. - **`nullPreviousReadDetail`.** A column read off a `null` `previous` (`record.stage != previous.stage`) still has no value on insert and still refuses. The refusal now names `previous.KEY` and gives the guards: `previous == null || …` judges an insert, `previous != null && …` skips one. Without this, the generic text would have said "a field this object declares, but its value was not supplied". All four refusal builders carry it. ### Hook conditions (`packages/objectql/src/hook-wrappers.ts`) - **Same scope as validation.** A declarative hook `condition` is now evaluated in `ruleScope`. `pickPreviousPayload` returns `null` on `beforeInsert` / `afterInsert`. On any other write that carries a prior row, it returns that row, copied and made total as before. When an existing row's prior state is not in hand, `previous` stays unbound. - **`ctx.previous` is untouched.** The engine's pre-image, which the after-hooks observe, is not changed. Only the CEL binding is. - **The refusal names the read.** A column read off the `null` on insert still aborts the operation (#4775). `unevaluableConditionError` names the read with the same `nullPreviousReadDetail` sentence. - **Docblock rewritten.** The "When it is ABSENT" section of the docblock is now "When it is `null`, and when it is ABSENT". It keeps the update-without-row reasoning. ### Docs (`content/docs/data-modeling/formulas.mdx`) The `previous` row of the scope table now says the following. `previous` is the row before the update. It is `null` on insert in validation, hook conditions and flows, so `previous == null` judges an insert. A column read off it there has no value and faults. No other sentence on the page says `previous` is unbound on insert. `engine.ts` is not edited. The insert call site hands the evaluator `mode: 'insert'`, and the binding derives from that. The validate-only preview's insert mode judges the same way. ## Census (triage's instruction) ### Validation rules reading `previous` Searched at `b0d12e2e9` across `packages/**` (platform objects, plugins, services) and `examples/**`, non-test, TS and non-TS metadata. | Rule | File | Condition | Insert verdict before | Insert verdict after | |---|---|---|---|---| | `validity_window_order` (`cross_field`, error) | `packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts` | `(previous == null` or a bound moved`)` and both bounds set and `valid_until` at or before `valid_from` | inverted / equal window: refused, 400, `reason: unevaluable`. Valid, one-bound or open window: admitted | inverted / equal window: refused, 400, the rule's own message. Valid, one-bound or open window: admitted | That is the only shipped validation rule that reads `previous`. Its verdict cannot move, for this reason: - **Before:** the first clause was a CEL error, which `&&` absorbs whenever another conjunct is `false`. - **After:** the first clause is `true` on insert, and the rule answers the remaining conjuncts. So the same writes are refused and the same writes are admitted. No field `requiredWhen` / `readonlyWhen` / `visibleWhen` in shipped metadata reads `previous`. The other `*.object.ts` hits are prose: comments, field descriptions and action copy in `sys-metadata-history`, `sys-account`, `sys-member`, `sys-oauth-application`, `sys-oauth-consent`, `sys-two-factor`, `sys-user-preference`, `sys-setting-audit` and `sys-audit-log`. ### Declarative hook conditions reading `previous` Searched at `a2863de08` across `packages/**` and `examples/**`. | Hook | File | Events | Condition | Insert verdict before | Insert verdict after | |---|---|---|---|---|---| | `showcase_audit_task_completion` | `examples/app-showcase/src/data/hooks/index.ts` | `afterUpdate` | `previous.done != true && record.done == true` | not dispatched on insert | not dispatched on insert (unchanged) | | `showcase_guard_task_reopen` | `examples/app-showcase/src/data/hooks/index.ts` | `beforeUpdate` | `previous.done == true && record.done != true` | not dispatched on insert | not dispatched on insert (unchanged) | No other shipped declarative hook condition names `previous`. Plugin and service hooks read `ctx.previous` in JS, not in a CEL condition. The 16 flow start conditions that read `previous` (`examples/app-showcase/src/automation/flows/**`, `examples/app-todo/src/flows/task.flow.ts`) are the flow engine's, which already binds `null` on insert. ## Verification ### CEL seam Measured against the built `@objectstack/formula`. | Expression | `previous` | Result | |---|---|---| | `previous == null` | unbound | `type: Unknown variable: previous` | | `previous == null` | `null` | `true` | | `previous != null && record.a > 5` | `null` | `false` | | `previous.x` | `null` | `runtime: No such key: x` | | `previous == null \|\| record.a != previous.a` | `null` | `true` | | `has(previous.x)` | `null` | `false` | `buildScope` (`packages/formula/src/stdlib.ts`) binds `previous` only when it is not `undefined`. ### Pins - **`packages/objectql/src/validation/rule-previous-insert-binding.test.ts`** (new) pins these cases: - A guarded predicate refuses a violating insert with its own message and no `unevaluable` reason. It admits a valid insert, and still reads the prior row on update. - A `conditional` with `when: previous == null` runs its branch on insert. - `requiredWhen: previous == null` is `required` on insert. - An unguarded `previous.KEY` read refuses as `unevaluable`, naming `previous.KEY`. - An update with no prior row keeps `previous` unbound, so `previous != null && …` is refused, not admitted. - **`packages/objectql/src/hook-condition-previous-scope.test.ts`.** The insert case flipped from "leaves `previous` UNBOUND on insert" to two cases: - `previous == null && record.done == true` fires on `beforeInsert` and `afterInsert`, and the engine's `ctx.previous` stays `undefined`. - `previous.done != true && record.done == true` on `beforeInsert` still aborts with a `HookConditionError`, `reason: 'unevaluable'`, and a message naming `'previous.done'`. - **`packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.ts`** asserts that every refusal carries `validity_window_order`'s own declared message, read off the object, and no `unevaluable` reason. It drives the real `ObjectQL` insert path over a real SQL driver with the real `SecurityPlugin`. ### Red, then green **Validation pins.** - *Red* on `099cc9d69` (uncommitted pins): objectql pin 5 failed / 7; window-order 4 insert refusals failed. - *Green* at `70374639e`: 7 / 7 and 11 / 11. The objectql `local` suite: 435 files, 8220 tests passed. - *Ablation* (`scripts/ablation-replace.mjs`): `if (mode === 'insert') return null;` to `return undefined;` gave 5 / 7 and 4 / 11 failed. The restore was proven: blob equal to HEAD, `git diff HEAD` empty. **Hook pin.** - *Red* on `b0d12e2e9` (flipped test, unchanged source): 2 failed / 25. The message received was `… (type: Unknown variable: previous) …`. - *Green* after the change: 25 / 25. - *Ablation* at `ee0ed2834`: `if (isInsertEvent(ctx.event)) return null;` to `return undefined;` gave 2 failed / 25. The restore was proven: blob `fb6056cc7567` equal to HEAD, `git diff HEAD` empty. No `dist/` is involved in either suite. objectql's tests import its source, and plugin-security aliases `@objectstack/objectql` to `src/index.ts`. **Final head `a2863de08`**, through `scripts/pm/os-verify-lock.sh`: - objectql `src/validation/`, `src/hook-*`, `src/engine-after-hook-abort-unit.test.ts` and `src/bulk-write-per-row-hooks.test.ts`: 41 files, 1300 passed. - `plugin-security` window-order: 11 / 11. - `pnpm --filter @objectstack/objectql typecheck`: `tsc --noEmit` plus `check:test-typecheck` OK, with no new ledger signature. ### REST door On a stock showcase boot of `b0d12e2e9`, `POST /api/v1/data/sys_user_permission_set` with `valid_from: 2027-01-02T00:00:00Z` and `valid_until: 2027-01-01T00:00:00Z`: - It answered **400** `VALIDATION_FAILED`. `fields[0]` = `{ field: valid_until, code: rule_violation, message: "Valid Until must be later than Valid From. …" }`, with no `constraint`. - The valid window answered **201**. ### Gates at `a2863de08` **Derived set.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 100 commands, and all 100 were run. - **First pass:** 97 exited 0. Three refused on a prerequisite and were not measurements: - `check:skill-examples` (needs the `client-react` declarations); - `check:dual-build-cjs-loads` (needs a workspace build); - `check:i18n` (needs the built CLI closure). - **After a workspace `turbo run build`:** all three exited 0. - **Reconciliation:** `dispatch-gates --ran` reported "100 derived, 100 run, 0 NOT-MEASURED, 0 UNRUN". - **Docs gate:** `pnpm check:doc-authoring` exited 0. **Changeset gates.** - `node scripts/check-changeset-no-major.mjs --base origin/main` exited 0: "This diff introduces no `major` bump". Its level axis reads the PR body, and a local run has none. - The level axis was also driven offline with `--event` and this body: exit 0, "LEVEL AXIS: this PR declares clause-② `yes (widening)`, and no package whose published source … it moves is graded `patch`". - `node scripts/check-adr-0087-registration.mjs --base origin/main` exited 0: "this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)". It reads `yes (widening)` as not breaking. ## Acceptance notes - **Published skill text (`skills/**`).** It still describes `previous` as unbound on insert: - `skills/objectstack-data/references/data-hooks.md`: the "`previous` is UNBOUND where there is no prior state" bullet, and the "a `previous` reference on an insert" clause. - `skills/objectstack-formula/SKILL.md`: the scope-table row "Insert events …, validation rule on insert | **unbound**", plus the note under the migration table. That surface is governed (Tier H) and outside this card's surface, so it is not edited here. The seat filed the correction as #23983 (`domain:skills`), to land after this PR. Its advice ("write insert-event conditions over `record` alone"; a `previous.x` read on insert fails the write) is still safe. Its "unbound" wording is now false: `previous == null` evaluates on insert. - **Dogfood comment.** `packages/qa/dogfood/test/hook-after-condition-rollback.dogfood.test.ts` comments that `previous` is unbound on an insert. Its condition, `previous.name != record.name`, still faults on insert, because the column read off `null` faults. So the test's verdict is unchanged; only the comment's reason is stale. The objectql twin's comment (`engine-after-hook-abort-unit.test.ts`) is corrected here. - **`EvalContext.previous` type.** In `@objectstack/formula` it is typed for a row only, while `buildScope` already binds `null`. Both surfaces therefore route `null` through `extra`. --- _Generated by [Claude Code](https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9f12417 commit b25d98a

8 files changed

Lines changed: 469 additions & 88 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
Clause-②: yes (widening)
6+
7+
On insert, `previous` is now bound to `null` in validation rules and in declarative hook
8+
conditions, so a rule or condition that reads it decides the insert — the binding a flow's start
9+
condition already gets on its create leg. Until now `previous` was unbound on insert: any
10+
reference to it faulted `Unknown variable: previous`, and the write was refused (validation) or
11+
aborted (hook) as `unevaluable`. So this widens what an insert can pass: a rule shaped
12+
`previous != null && …` ("judge updates only") now ADMITS the inserts it used to refuse with that
13+
fault, and a field's `requiredWhen: previous == null` now requires the field on insert instead of
14+
refusing every insert. `previous == null || …` judges an insert, `previous != null && …` skips
15+
one. A column read off `previous` on insert (`previous.stage`) still has no value and still
16+
refuses the write; the refusal now names that read and the guard to add.
17+
18+
A violating insert under a rule that reads `previous` now answers with the rule's own message.
19+
The platform's own `validity_window_order` on `sys_user_permission_set` is the measured case: an
20+
inverted grant window answers `400 VALIDATION_FAILED` with "Valid Until must be later than Valid
21+
From …", not a CEL fault.
22+
23+
The binding is the same for every predicate a write evaluates — a `script` / `cross_field`
24+
condition, a `conditional`'s `when`, a field's `requiredWhen` and an option's `visibleWhen` —
25+
and for a hook's `condition` on `beforeInsert` / `afterInsert`; the validate-only preview judges
26+
an insert the same way. Only the CEL binding is `null`: a hook's `ctx.previous` is unchanged. No
27+
shipped rule's verdict changes: every rule and hook this repository ships that reads `previous`
28+
refused and admitted the same inserts before and after; only the refusal's reason moved. A write
29+
to an existing row whose prior state is not in hand keeps `previous` unbound and still fails
30+
closed, so `previous == null` never reads an update as an insert.

‎content/docs/data-modeling/formulas.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ Keep them pure, dependency-free, and AI-readable.
208208
| Binding | Source | Available in |
209209
|:---|:---|:---|
210210
| `record` | the row being evaluated | formulas, validation, sharing, visibility |
211-
| `previous` | row before update — on a `multi: true` write, that row's own pre-write state, in `before*` and `after*` hooks alike and in record-change triggers: since ADR-0058 Addendum II both phases dispatch once per matched row | hooks, validation on update |
211+
| `previous` | the row before the update — on a `multi: true` write, that row's own pre-write state, in `before*` and `after*` hooks alike and in record-change triggers: since ADR-0058 Addendum II both phases dispatch once per matched row. `null` on insert, so `previous == null` judges an insert; a column read off it there (`previous.status`) has no value and faults | validation, hook conditions and flows — `null` on insert |
212212
| `input` | hook payload | hooks |
213213
| `current_user` | the authenticated subject — the canonical binding (ADR-0068). `user`, `ctx.user` and `os.user` are aliases of the **same** object | predicates with identity |
214214
| `os.user` | alias of `current_user` | seed, predicates with identity |

‎packages/objectql/src/engine-after-hook-abort-unit.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ describe('#22782 — an aborting after* hook rolls its write back', () => {
179179
* condition can abort its write too, and the door's unit rolls the row back.
180180
*/
181181
describe('#22856 — an after* hook carrying a condition rolls its write back when the condition cannot be evaluated', () => {
182-
/** `previous` is unbound on an insert, so this condition cannot be evaluated there. */
182+
/** `previous` is `null` on an insert, so a column read off it cannot be evaluated there. */
183183
const unbindable = 'previous.name != record.name';
184184
/** A key `case` does not declare: unevaluable on every event. */
185185
const undeclared = 'record.stauts == "x"';

‎packages/objectql/src/hook-condition-previous-scope.test.ts‎

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,17 @@
2020
* - an UNDECLARED key stays unevaluable, so typos remain reportable;
2121
* - copied, never mutated in place — the after-hooks that run next observe
2222
* the engine's own `ctx.previous`;
23-
* - absent (unbound identifier) whenever the prior state is not in hand:
24-
* insert, and a predicate bulk update.
23+
* - `null` on an insert event (#23910): there is no prior row, so
24+
* `previous == null` judges the insert, and a column read off it
25+
* (`previous.done`) still faults loudly;
26+
* - absent (unbound identifier) on any other context whose prior state is
27+
* not in hand — an unknown row is never bound as `null`.
2528
*/
2629

2730
import { describe, it, expect, vi, beforeEach } from 'vitest';
2831
import { ObjectQL } from './engine.js';
2932
import { bindHooksToEngine } from './hook-binder.js';
30-
import { wrapDeclarativeHook } from './hook-wrappers.js';
33+
import { wrapDeclarativeHook, HookConditionError } from './hook-wrappers.js';
3134
import type { Hook, HookContext } from '@objectstack/spec/data';
3235

3336
/** `archived` is declared but never written by any test here — the
@@ -150,27 +153,55 @@ describe('[#4784] hook condition binds `previous` alongside `record`', () => {
150153
expect((ctx.previous as any).archived).toBeUndefined();
151154
});
152155

153-
it('leaves `previous` UNBOUND on insert — verbatim the validation side', async () => {
156+
it('binds `previous` to NULL on insert — the validation side\'s rule — so `previous == null` judges the insert', async () => {
157+
const calls: string[] = [];
158+
const { logger, conditionWarnings } = captureLogger();
159+
// [#23910] An insert has no prior row, and that is a fact the condition
160+
// may read: `previous` is bound to `null` on an insert event, exactly as a
161+
// validation rule's scope and a flow's create leg bind it. Until then it
162+
// was unbound, so `previous == null` faulted `Unknown variable: previous`
163+
// and #4775 aborted the insert.
164+
const wrapped = wrapDeclarativeHook(
165+
makeHook('previous == null && record.done == true', ['beforeInsert', 'afterInsert']),
166+
(async () => { calls.push('ran'); }) as any,
167+
{ logger },
168+
);
169+
170+
const before = makeCtx({ event: 'beforeInsert', previous: undefined, input: { data: { done: true } } } as any);
171+
await wrapped(before);
172+
await wrapped(makeCtx({ event: 'afterInsert', previous: undefined, input: { data: { done: true } } } as any));
173+
expect(calls).toEqual(['ran', 'ran']);
174+
expect(conditionWarnings()).toEqual([]);
175+
// Only the CEL binding is null: the engine's own `ctx.previous`, which the
176+
// after-hooks observe, is untouched.
177+
expect(before.previous).toBeUndefined();
178+
});
179+
180+
it('NEGATIVE — a COLUMN read off the null `previous` on insert still faults loudly, naming the read', async () => {
154181
const calls: string[] = [];
155182
const { logger } = captureLogger();
156-
// `rule-validator.ts` binds `previous` only for `mode: 'update'` with a
157-
// prior record in hand; on insert it passes `undefined`, which omits the
158-
// identifier from the CEL scope. Referencing it on an insert event is
159-
// therefore an author error, reported — not silently answered.
160183
const wrapped = wrapDeclarativeHook(
161184
makeHook(TRANSITION, ['beforeInsert']),
162185
(async () => { calls.push('ran'); }) as any,
163186
{ logger },
164187
);
165188

166-
// #4775: an unbound `previous` is unevaluable, and unevaluable rejects the
167-
// insert rather than being swallowed into `false`.
168-
await expect(wrapped(makeCtx({
169-
event: 'beforeInsert',
170-
previous: undefined,
171-
input: { data: { done: true } },
172-
} as any))).rejects.toThrow(/Unknown variable: previous/);
189+
// #4775: unevaluable rejects the insert rather than being swallowed into
190+
// `false` — `previous` has no columns on an insert.
191+
let err: any = null;
192+
try {
193+
await wrapped(makeCtx({
194+
event: 'beforeInsert',
195+
previous: undefined,
196+
input: { data: { done: true } },
197+
} as any));
198+
} catch (e) {
199+
err = e;
200+
}
173201

202+
expect(err).toBeInstanceOf(HookConditionError);
203+
expect(err.reason).toBe('unevaluable');
204+
expect(String(err.message)).toContain("'previous.done'");
174205
expect(calls).toEqual([]);
175206
});
176207

‎packages/objectql/src/hook-wrappers.ts‎

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { ExpressionEngine } from '@objectstack/formula';
2222
import { noopHookMetricsRecorder, type HookMetricsRecorder, type HookMetricOutcome } from './hook-metrics.js';
2323
import { materializeDeclaredFields } from './declared-fields.js';
2424
import { describeCelFault, type CelFault } from './cel-fault.js';
25+
import { nullPreviousReadDetail, ruleScope, type PreviousBinding } from './validation/rule-validator.js';
2526
import { hookRunAs, deriveHookApi, type HookRunAs } from './hook-run-as.js';
2627

2728
/**
@@ -281,8 +282,9 @@ export function declaredHookCondition(meta: Pick<Hook, 'condition'>): (Expressio
281282
* the platform can no longer paper over.
282283
* - `previous` — the record's pre-write state, built by
283284
* {@link pickPreviousPayload}: `ctx.previous`, made total over the same
284-
* declared fields. Absent (an unbound CEL identifier) whenever the prior
285-
* state is not in hand, exactly as on the validation side.
285+
* declared fields; `null` on an insert event, which has no prior row; and
286+
* absent (an unbound CEL identifier) whenever an existing row's prior
287+
* state is not in hand — exactly as on the validation side (#23910).
286288
*
287289
* `previous` is what makes a TRANSITION expressible. Since #4770 `record` is
288290
* the record's STATE, so `record.done == true` is true on every update of an
@@ -313,18 +315,17 @@ export function wrapDeclarativeHook(
313315
const check = ExpressionEngine.compile(expr);
314316
if (check.ok) {
315317
conditionFn = (ctx: HookContext) => {
316-
// `previous` is passed through as-is: `undefined` means the binding
317-
// is OMITTED from the CEL scope (see `buildScope` in
318-
// @objectstack/formula), which is exactly what the validation side
319-
// does when no prior record is in hand. Binding it to an empty
320-
// object instead would answer `previous.x == null` with a
321-
// fabricated "yes" for a record whose prior state is unknown.
318+
// [#23910] The scope is the validation side's, built by the same
319+
// `ruleScope`: `previous` is `null` on an insert event, the prior row
320+
// on any other write that carries one, and OMITTED from the CEL scope
321+
// (`undefined`, see `buildScope` in @objectstack/formula) when an
322+
// existing row's prior state is not in hand — never invented.
322323
const record = pickRecordPayload(ctx);
323324
const previous = pickPreviousPayload(ctx);
324-
const r = ExpressionEngine.evaluate<boolean>(expr, { record: record ?? {}, previous });
325+
const r = ExpressionEngine.evaluate<boolean>(expr, ruleScope(record ?? {}, previous));
325326
if (!r.ok) {
326327
// [#4775] Fail LOUD. Not `false` — see `HookConditionError`.
327-
throw unevaluableConditionError(meta, ctx, source, r.error);
328+
throw unevaluableConditionError(meta, ctx, source, r.error, previous);
328329
}
329330
return Boolean(r.value);
330331
};
@@ -991,11 +992,20 @@ function unevaluableConditionError(
991992
ctx: HookContext,
992993
source: string,
993994
error: CelFault,
995+
previous?: PreviousBinding,
994996
): HookConditionError {
995-
const { summary, missingKey, detail } = describeCelFault(error, {
997+
const described = describeCelFault(error, {
996998
what: 'condition',
997999
undeclaredKeyFix: "fix the hook's condition, or declare the field",
9981000
});
1001+
const { summary } = described;
1002+
// [#23910] A column read off an insert's `null` previous gets the validation
1003+
// side's sentence (`nullPreviousReadDetail`): "this object does not declare"
1004+
// would be false for a declared column. Like the validation side, a sentence
1005+
// worded for the fault carries no generic `missingKey`.
1006+
const nullRead = nullPreviousReadDetail(error, source, previous, declaredFieldsFor(ctx), 'condition');
1007+
const detail = nullRead ?? described.detail;
1008+
const missingKey = nullRead === undefined ? described.missingKey : undefined;
9991009
const head = `Hook '${meta.name}' could not evaluate its condition (${summary}) — operation aborted.`;
10001010
return new HookConditionError(`${head}${detail}`, {
10011011
hook: meta.name,
@@ -1182,26 +1192,31 @@ function pickRecordPayload(ctx: HookContext): any {
11821192
* handlers columns the row never had (#4649 left the same note on the
11831193
* validation side).
11841194
*
1185-
* ## When it is ABSENT — verbatim the validation side's rule
1195+
* ## When it is `null`, and when it is ABSENT — verbatim the validation side's rule
11861196
*
1187-
* `rule-validator.ts` binds `previous` only for `mode: 'update'` with a prior
1188-
* record actually fetched, and passes `undefined` otherwise, which omits the
1189-
* identifier from the CEL scope. Same here:
1190-
* - **insert** — there is no prior state, so `previous` is unbound and any
1191-
* reference to it is an author error, reported as such;
1192-
* - **any update-shaped context whose prior row is not in hand** — with no
1193-
* row there is no single prior record to bind, so `previous` stays unbound
1194-
* rather than being invented. A predicate (`multi: true`) bulk write is no
1195-
* longer one of these: since #5574 (ADR-0058 Addendum II, D1/D2) it
1196-
* dispatches BOTH phases per matched row — `after*` since #5038 — each
1197-
* context binding that row's own pre-image, so a transition condition
1198-
* reads exactly as it does on a single-record write.
1199-
* Binding `null`/`{}` instead would make `previous.x == null` answer "yes"
1200-
* for a record whose prior state is simply unknown — a fabricated fact, the
1201-
* one thing materialisation is careful never to do.
1197+
* `rule-validator.ts` decides the binding once per evaluation
1198+
* (`previousBinding`) and both surfaces build their scope with the same
1199+
* `ruleScope` (#23910). Same rule here:
1200+
* - **insert** (`beforeInsert` / `afterInsert`) — `null`. There is no prior
1201+
* row, and that is a FACT a condition may read: `previous == null` is how
1202+
* a condition says "this write creates the row", the binding a flow's
1203+
* create leg and a validation rule's insert already get. A COLUMN read off
1204+
* it (`previous.done`) still has no value, faults, and aborts the
1205+
* operation (#4775), with a sentence naming the read and the guard to add.
1206+
* Only the CEL binding is `null`: `ctx.previous`, the engine's pre-image,
1207+
* is not touched.
1208+
* - **any other context whose prior row is not in hand** — unbound. The row
1209+
* exists and its state is unknown, so `previous` stays unbound rather than
1210+
* being invented: `null` there would answer `previous == null` with "this
1211+
* is an insert", a fabricated fact, the one thing materialisation is
1212+
* careful never to do. A predicate (`multi: true`) bulk write is no longer
1213+
* one of these: since #5574 (ADR-0058 Addendum II, D1/D2) it dispatches
1214+
* BOTH phases per matched row — `after*` since #5038 — each context
1215+
* binding that row's own pre-image, so a transition condition reads
1216+
* exactly as it does on a single-record write.
12021217
*/
1203-
function pickPreviousPayload(ctx: HookContext): Record<string, unknown> | undefined {
1204-
if (isInsertEvent(ctx.event)) return undefined;
1218+
function pickPreviousPayload(ctx: HookContext): PreviousBinding {
1219+
if (isInsertEvent(ctx.event)) return null;
12051220
const prior = ctx.previous;
12061221
if (!prior || typeof prior !== 'object' || Array.isArray(prior)) return undefined;
12071222
return materializeDeclaredFields({ ...(prior as Record<string, unknown>) }, declaredFieldsFor(ctx));

0 commit comments

Comments
 (0)