Skip to content

Commit e7d971d

Browse files
fix(lint): null-guard gate accuracy follow-ups: the runtime door's bare start-guard gap recorded, the summary-nulls presumption stated, an insert-safe guard for previous (#29582)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8DzVG18Zt4iPwaWCwCY4Uy6)_ Fixes #29491 Clause-②: no Metadata-protocol: none Dispatch: Claim 6586327517 · Thread-read 6584817890 · Source: contract review 6582253324 on PR #29474 ## 维护者速读 - 改了什么:null-guard 门禁三处准确性修正:记下运行时发布门对「start 条件里的裸名守卫」比 `os validate` 更严;roll-up 汇总恒有值的判断写明以跑过 `os migrate summary-nulls` 为前提;`previous.x` 的报错补一句插入时也成立的完整守卫写法。 - 为什么改:合约复核 6582253324 的三条 should-fix:一个流在构建门过、发布门被拒却无处记载;注释把一次性迁移说成自动回填;`previous.x != null` 在插入时本身就会出错,旧提示只给了半个守卫。 - 风险与回滚:拒收哪些谓词完全不变,只改一条报错的结尾文字、两处注释和 changeset 文字;回滚即 revert 本 PR。待发布的 29026 changeset 有两句被刻意更正(原文与新文见 Acceptance notes),所以 Check Changeset 预期会红。 - 先看:`packages/lint/src/validate-null-guards.ts` 的 `nullGuardMessage` 结尾与 `PREVIOUS_GUARD_CLAUSE`;再看 `.changeset/29026-flow-condition-null-guard.md` 的两句改动;最后 `validate-expressions.flow-null-guards.test.ts` 新增的两门对照用例。 - 两分钟验证:`pnpm --filter @objectstack/lint exec vitest run src/validate-null-guards.test.ts src/validate-expressions.flow-null-guards.test.ts`,预期 46 个用例全过。 - 需要你做的:无,席位落地。 ## What changes 1. **The runtime-door gap is recorded, not narrowed** (the order's default arm). At `os validate`, where a record-triggered flow's bound set is provable, a bare start condition `budget != null` proves `record.budget`, so an edge comparing `record.budget > 200000` passes. The runtime publish gate judges the written flow alone (`perWriteSnapshotEntrance`), its bound set is not provable, no bare name is judged, the bare guard proves nothing, and the same edge is refused. That stricter side stands; the ledger now says so and says why: - `validate-null-guards.ts`, surface ledger flow row: the verdict cell gains "the runtime publish gate is stricter on a bare start guard; see below", and wiring point 3 ("The start condition gates every run") gains the paragraph naming the two doors, the reason deriving the presumption there would be unsound, the cure (`record.budget != null` in the start condition, accepted at both doors), and the test that pins it. No runtime stand-down was taken: nobody measured one sound. - `.changeset/29026-flow-condition-null-guard.md` L15, the runtime-gate sentence (deliberate correction, quoted below). 2. **`isAlwaysValuedSummary` no longer says the engine backfills.** `validate-expressions.ts` doc comment: the engine seeds a new parent at create time (`initializeSummaryFields`) and falls back on a child write's recompute; nothing backfills a stored `NULL` on its own. The count/sum judgment presumes the one-off `os migrate summary-nulls` (`summary-backfill.ts`) ran; on a database upgraded in place without it, such a parent reads `null` and a validation rule that this gate passed refuses the write. The 29026 changeset's roll-up sentence (L17) says the same (deliberate correction, quoted below). 3. **A `previous.` operand's message names the guard that holds on an insert.** `nullGuardMessage` appends `PREVIOUS_GUARD_CLAUSE` after the shared hint, only for an operand spelled `previous.FIELD`: "On an insert `previous` is itself null, so `previous.budget != null` alone faults there too: guard it with `previous != null && previous.budget != null`." `NULL_GUARD_HINT` is untouched, so it still reads word for word as objectql's rule-validator sentence. The clause applies on every surface: a validation rule and a hook condition bind `previous` to `null` on an insert (`previousBinding`), as a flow's create leg does (`seedRunVariables`). The ledger's "Not reached" paragraph points at it. New patch changeset `.changeset/29491-previous-guard-hint.md`. ## Tests - `validate-null-guards.test.ts`, new case: for `previous.budget > 5`, under both `fail-closed` and `run-fails`, the message carries the verbatim hint followed by the clause naming `previous != null && previous.budget != null`; a `record.budget` operand and a bare `budget` operand get no clause; the prescribed guard passes the gate; and evaluated with `previous` bound to `null` (as on an insert) the full guard answers `false` while `previous.budget != null && …` alone faults. - `validate-expressions.flow-null-guards.test.ts`, new case: a start condition `budget != null` with an edge `record.budget > 200000` gives no finding through `validateStackExpressions` (build door) and exactly one, on `edge 'e2'`, through `runStackExpressionPasses(…, { runtimeWriteType: 'flow' })` (runtime door); the `record.budget != null` spelling gives none at either door. - Ablation (committed fix first, `scripts/ablation-replace.mjs`, trap-restored): replacing the clause expression with an empty string turned the new message case red (Tests 1 failed / 33 passed); restore proved by blob equal to HEAD and an empty `git diff HEAD`. - `pnpm --filter @objectstack/lint test` at d4f21b7: Test Files 192 passed (192), Tests 7882 passed, 5 skipped. `pnpm --filter @objectstack/lint typecheck` (tsc, `check:test-typecheck`): exit 0. ## Acceptance notes - **Deliberate correction of the pending changeset `.changeset/29026-flow-condition-null-guard.md`** (not yet released). Check Changeset is expected to read red on an edited existing changeset; that is the gate doing its job, and this paragraph is the confirmation. - L15 before: "At the runtime publish gate bare names are not judged." - L15 after: "At the runtime publish gate bare names are not judged, so a bare start-condition guard proves nothing there: a flow whose start condition is `budget != null` and whose edge compares `record.budget > 200000` passes `os validate` (where the flow's bound set is provable) and is refused by the runtime publish gate. Spell the start guard `record.budget != null` and both accept it." - L17 before: "A `count` or `sum` roll-up summary is now always valued for this gate (zero children is zero), on validation rules and hook conditions too; `min` / `max` / `avg` roll-ups stay nullable." - L17 after: the same sentence, then "That presumes `os migrate summary-nulls` ran: on a database upgraded in place without it, a parent row stored before roll-ups were seeded at create time, and no child of it written since, still reads `null`." - Which predicates either door refuses does not change. Item 1 took the record arm, so `Clause-②: no` and no contract review is owed by this PR. - `PREVIOUS_GUARD_CLAUSE` is module-private; the package's export list is unchanged. ## Verification <details><summary>dispatch-gates at d4f21b7: 58 derived, 57 exit 0 or the expected red, 1 NOT MEASURED</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 58 families against merge base ff827bc. Prerequisites first: `git fetch origin 621a487` (plugin-teardown self-test) and `turbo run build --filter='@objectstack/lint...' --filter=@objectstack/objectql` (exit 0). Then every family ran once from a runner recording `CMD :: exit N`: - 56 exited 0. - `node scripts/check-empty-changeset.mjs --base origin/main` exited 1 on `.changeset/29026-flow-condition-null-guard.md`, naming the DELIBERATE CORRECTION class. Expected; see Acceptance notes. The 29491 changeset is new and not flagged. - `pnpm check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: it needs every package built). NOT MEASURED locally; CI runs it. Reconciliation: "dispatch-gates --ran: 58 derived famil(ies) accounted for — 57 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)". `origin/main` moved six commits past the merge base while the gates ran (head ebbb5b8 and later); none touches `packages/lint`, `packages/formula`, `packages/objectql` or the 29026 changeset, and `git merge-tree` against it is clean. Not merged in; CI judges the merge ref. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (CI owns the repo-wide lint run) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (the `维护者速读` block is the maintainer digest the dispatch order requires) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbZNEUhFEaL9RRQWJRR)_ <!-- signed: domain:devx#1 · os-warren · session_01JQwxbZNEUhFEaL9RRQWJRR --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0ab4a09 commit e7d971d

6 files changed

Lines changed: 111 additions & 10 deletions

‎.changeset/29026-flow-condition-null-guard.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ Metadata-protocol: narrowed
1212
`os build`, `os validate`, `os lint` and the runtime publish gate now refuse a record-triggered flow (start `triggerType: 'record-…'`) whose start condition, edge `condition` or `decision` branch `expression` applies an ordering (`< <= > >=`) or arithmetic (`+ - * / %`) operator to a nullable declared field of the trigger object with no `!= null` / `== null` / `!isBlank()` test in front of it. The run's `record` and `previous` carry every declared field, nulls included, so such a comparison faults with `no such overload` on the first record where the field is empty, and the run fails there (the write that fired it still lands).
1313

1414
- **Who moves.** A flow condition such as `budget > 200000` on an optional `budget`. Fix: `budget != null && budget > 200000`, or put the `!= null` test in the flow's start condition, which gates every run, so every edge and branch after it may compare freely. `has(record.budget)` is not a guard: the key is always present.
15-
- **Bare names count.** The run spreads the record's fields to top level, so a bare `budget` is read as `record.budget`, unless the flow declares a variable or a node id of that name, or has a node that can bind names nobody declared (a `script`, `connector_action`, `wait`, `subflow` or `map`); those bare names are not judged. At the runtime publish gate bare names are not judged.
15+
- **Bare names count.** The run spreads the record's fields to top level, so a bare `budget` is read as `record.budget`, unless the flow declares a variable or a node id of that name, or has a node that can bind names nobody declared (a `script`, `connector_action`, `wait`, `subflow` or `map`); those bare names are not judged. At the runtime publish gate bare names are not judged, so a bare start-condition guard proves nothing there: a flow whose start condition is `budget != null` and whose edge compares `record.budget > 200000` passes `os validate` (where the flow's bound set is provable) and is refused by the runtime publish gate. Spell the start guard `record.budget != null` and both accept it.
1616
- **Not judged.** Flows with no record trigger (an `api` flow's request body, an action's or `map` item's record are not made total), a `screen` field's `visibleWhen`, and `config.condition` on any node but `start`.
17-
- **Fewer findings elsewhere.** A `count` or `sum` roll-up summary is now always valued for this gate (zero children is zero), on validation rules and hook conditions too; `min` / `max` / `avg` roll-ups stay nullable.
17+
- **Fewer findings elsewhere.** A `count` or `sum` roll-up summary is now always valued for this gate (zero children is zero), on validation rules and hook conditions too; `min` / `max` / `avg` roll-ups stay nullable. That presumes `os migrate summary-nulls` ran: on a database upgraded in place without it, a parent row stored before roll-ups were seeded at create time, and no child of it written since, still reads `null`.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
fix(lint): the null-guard message names the guard that holds on an insert for a `previous.` operand
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The publish-time null-guard gate (`os build`, `os validate`, `os lint` and the runtime publish gate) reports an ordering or arithmetic operator applied to a nullable declared field with no `!= null` test in front of it, and tells the author to add one. For a `previous.FIELD` operand that advice alone faults on an insert, where `previous` is itself `null` (on a validation rule, a hook condition and a record-triggered flow's create leg alike). The message for such an operand now ends: guard it with `previous != null && previous.FIELD != null`. The shared sentence before it is unchanged and still reads word for word as the runtime's. Which predicates are refused does not change. Nothing to migrate.

‎packages/lint/src/validate-expressions.flow-null-guards.test.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import { describe, it, expect } from 'vitest';
1010

11-
import { validateStackExpressions } from './validate-expressions.js';
11+
import { runStackExpressionPasses, validateStackExpressions } from './validate-expressions.js';
1212

1313
// `budget` and `owner` are optional, as on `showcase_project`; `name` is
1414
// required and `spent` has a default, so neither may ever be flagged.
@@ -91,6 +91,33 @@ describe('flow conditions under the null-guard gate (#29026)', () => {
9191
})])).toEqual([]);
9292
});
9393

94+
it('records the two doors disagreeing on a BARE start guard: the runtime publish gate refuses what `os validate` accepts (#29491)', () => {
95+
const guardedBy = (condition: string) => flow({
96+
nodes: [start({ condition }), { id: 'triage', type: 'decision' }, { id: 'alert', type: 'end' }, { id: 'calm', type: 'end' }],
97+
edges: [
98+
{ id: 'e1', source: 'start', target: 'triage' },
99+
{ id: 'e2', source: 'triage', target: 'alert', condition: 'record.budget > 200000' },
100+
{ id: 'e3', source: 'triage', target: 'calm', isDefault: true },
101+
],
102+
});
103+
const atRuntimeGate = (f: Rec) =>
104+
runStackExpressionPasses({ objects: [project], flows: [f] }, { runtimeWriteType: 'flow' })
105+
.filter((i) => i.message.includes('declares as nullable'));
106+
107+
// A bare guard: the build door proves `record.budget` from it, the runtime
108+
// door cannot (its bound set is not provable), so only the runtime refuses.
109+
const bare = guardedBy('budget != null');
110+
expect(nullGuardIssues([bare])).toEqual([]);
111+
const refused = atRuntimeGate(bare);
112+
expect(refused).toHaveLength(1);
113+
expect(refused[0]!.where).toContain("edge 'e2'");
114+
115+
// The `record.` spelling of the same guard is accepted at both doors.
116+
const spelled = guardedBy('record.budget != null');
117+
expect(nullGuardIssues([spelled])).toEqual([]);
118+
expect(atRuntimeGate(spelled)).toEqual([]);
119+
});
120+
94121
it('judges a `decision` branch expression', () => {
95122
const issues = nullGuardIssues([flow({
96123
nodes: [

‎packages/lint/src/validate-expressions.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -499,10 +499,19 @@ function isNullableField(def: AnyRec): boolean {
499499
/**
500500
* [#29026] A `count` / `sum` roll-up summary is always valued: zero children is
501501
* zero. `@objectstack/objectql`'s `summaryEmptySetValue` is the authority — the
502-
* engine seeds a new parent row with it, falls back to it when an aggregate
503-
* over no rows answers `null`, and backfills a stored `NULL` with it — and this
504-
* package may not import a runtime, so the two functions are named here. A
505-
* `min` / `max` / `avg` over no rows IS `null`, and stays nullable. Measured on
502+
* engine seeds a new parent row with it at create time
503+
* (`initializeSummaryFields`) and falls back to it when a child write's
504+
* recompute over no rows answers `null` — and this package may not import a
505+
* runtime, so the two functions are named here. A `min` / `max` / `avg` over
506+
* no rows IS `null`, and stays nullable.
507+
*
508+
* This judgment PRESUMES the one-off `os migrate summary-nulls` ran. Nothing
509+
* backfills a stored `NULL` automatically: a parent row stored before the
510+
* create-time seed shipped, and never visited by a child write since, keeps
511+
* its `NULL` until that migration (`summary-backfill.ts`) recomputes it. On a
512+
* database upgraded in place without it, such a row reads `null`, and a
513+
* predicate this gate passed faults on it — a validation rule then refuses the
514+
* write. Measured on
506515
* a record-triggered run: `showcase_expense_report.total_amount` (a `sum`)
507516
* reads `0` on a report with no lines
508517
* (`examples/app-showcase/test/notify-recipient-optional-fields.test.ts`).

‎packages/lint/src/validate-null-guards.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,30 @@ describe('nullGuardMessage', () => {
289289
expect(msg).toContain(NULL_GUARD_HINT);
290290
});
291291

292+
it('names the guard that holds on an insert for a `previous.` operand, and only for one (#29491)', () => {
293+
const fullGuard = '`previous != null && previous.budget != null`';
294+
const [prior] = find('previous.budget > 5');
295+
for (const outcome of ['fail-closed', 'run-fails'] as const) {
296+
const msg = nullGuardMessage("validation rule 'r'", 'showcase_project', prior!, outcome);
297+
// The shared sentence stays verbatim, and the clause follows it.
298+
expect(msg).toContain(`${NULL_GUARD_HINT} On an insert \`previous\` is itself null`);
299+
expect(msg).toContain(`guard it with ${fullGuard}.`);
300+
}
301+
const [current] = find('record.budget > 5');
302+
expect(nullGuardMessage("validation rule 'r'", 'showcase_project', current!)).not.toContain('previous');
303+
const [bare] = findUnguardedNullableOperands('budget > 5', { nullableFields, bareFields: new Set(['budget']) });
304+
expect(nullGuardMessage('edge', 'showcase_project', bare!, 'run-fails')).not.toContain('previous');
305+
306+
// The guard it names passes this gate, and is the one that evaluates on
307+
// an insert, where `previous` is bound to null: the `!= null` test alone
308+
// faults there.
309+
expect(find('previous != null && previous.budget != null && previous.budget > 5')).toEqual([]);
310+
const onInsert = (source: string) =>
311+
celEngine.evaluate({ dialect: 'cel', source }, { record: { budget: 1 }, extra: { previous: null } });
312+
expect(onInsert('previous != null && previous.budget != null && previous.budget > 5')).toMatchObject({ ok: true, value: false });
313+
expect(onInsert('previous.budget != null && previous.budget > 5').ok).toBe(false);
314+
});
315+
292316
it('closes with the runtime rejection wording verbatim (one voice, two gates)', () => {
293317
// Lifted from `unevaluableRuleError` in
294318
// packages/objectql/src/validation/rule-validator.ts — if that text moves,

‎packages/lint/src/validate-null-guards.ts‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@
9090
* | field `requiredWhen` | TOTAL | same `merged` in `evaluateValidationRules` — fail-OPEN, so an unguarded predicate enforces NOTHING in silence | covered (#4811) |
9191
* | field `readonlyWhen` | TOTAL | `rule-validator.ts` `readonlyWhenBindings` materialises BOTH roots (#4953 clause 1, landed in #6454) | excluded — NOT on totality; see below |
9292
* | action `visible` / `disabled` | sparse | evaluated client-side; no materialization exists in `objectui` | excluded (decided — #4953 clause 2) |
93-
* | flow start / edge / `decision` condition — record-triggered flow | TOTAL | `record-change-trigger.ts` `buildContext` layers `previous` under the payload/after-row then runs BOTH `record` and `previous` through a structural-mirror `materializeDeclaredFields` (#4953 clause 1's other half, services lane); `seedRunVariables` flattens that same total record to bare names | covered (#29026), outcome `run-fails`; see below |
93+
* | flow start / edge / `decision` condition — record-triggered flow | TOTAL | `record-change-trigger.ts` `buildContext` layers `previous` under the payload/after-row then runs BOTH `record` and `previous` through a structural-mirror `materializeDeclaredFields` (#4953 clause 1's other half, services lane); `seedRunVariables` flattens that same total record to bare names | covered (#29026), outcome `run-fails`; the runtime publish gate is stricter on a bare start guard; see below |
9494
* | the same slots, any other flow | sparse or unknown | the inbound hook hands the request body, an action an empty record carrying at most `id`, a `map` item its own keys; nothing materializes them | excluded |
9595
* | sharing-rule `condition` | n/a | compiled to a SQL filter; `NULL > x` is three-valued, never faults | excluded |
9696
* | field `expression` (`Field.formula`) | n/a | product judgement, not a wiring gap — see below | excluded |
@@ -266,6 +266,21 @@
266266
* before any node, whatever the entrance, so what it proves non-null
267267
* is presumed at every edge and `decision` branch of the flow
268268
* ({@link provenNonNullKeys}); an author does not re-guard downstream.
269+
* **The two doors disagree on a BARE start guard, and the runtime
270+
* publish gate is the stricter one.** `os validate` sees the flow's
271+
* entrances, so where the bound set is provable a bare
272+
* `budget != null` start condition proves `record.budget` (point 2)
273+
* and an edge comparing `record.budget > 200000` passes. The runtime
274+
* publish gate judges the written flow alone
275+
* (`perWriteSnapshotEntrance`): the bound set is not provable there,
276+
* no bare name is judged, the bare guard proves nothing, and the same
277+
* edge is refused. The stricter side stands: with the bound set not
278+
* provable, a bare name is not provably the field, and deriving the
279+
* presumption from it would be unsound. Nobody has measured a sound
280+
* way for that door to stand down on such a finding instead, so it
281+
* does not. Spell the start guard `record.budget != null` and
282+
* both doors accept the flow. Pinned in
283+
* `validate-expressions.flow-null-guards.test.ts`.
269284
* 4. **Outcome `run-fails`, not `fail-closed`.** `evaluateCondition`
270285
* throws on the fault (ADR-0032 §1c) and the record-change trigger
271286
* isolates the write, so the run fails and the write lands.
@@ -275,7 +290,10 @@
275290
* `previous.x != null` itself faults there; the full guard is
276291
* `previous != null && previous.x != null`. The gate still reports the
277292
* unguarded `previous.x` (it faults on the update leg's null), and
278-
* `previous != null` alone is not accepted as guarding `previous.x`.
293+
* `previous != null` alone is not accepted as guarding `previous.x`. The
294+
* message names that full guard for a `previous.` operand
295+
* ({@link PREVIOUS_GUARD_CLAUSE}), on every surface: a validation rule
296+
* and a hook condition bind `previous` to `null` on an insert too.
279297
* Other declared predicate slots (a `screen` field's `visibleWhen`) are
280298
* not judged: their fault does not take this outcome, and nobody has
281299
* measured what it does take.
@@ -304,6 +322,18 @@ export const NULL_GUARD_HINT =
304322
`Guard it with '!= null'` +
305323
` — 'has(x)' does NOT do that: a declared field holding null is still PRESENT, so has(x) is true.`;
306324

325+
/**
326+
* The clause {@link nullGuardMessage} adds after {@link NULL_GUARD_HINT} for a
327+
* `previous.<f>` operand, and only for one. An insert binds `previous` itself to
328+
* `null` (`previousBinding` in the rule validator, `seedRunVariables` on a
329+
* flow's create leg), so `previous.<f> != null` faults there too and the guard
330+
* that holds on both legs names `previous` first. Kept out of the shared hint,
331+
* which stays verbatim with the runtime's sentence.
332+
*/
333+
const PREVIOUS_GUARD_CLAUSE = (operand: string): string =>
334+
` On an insert \`previous\` is itself null, so \`${operand} != null\` alone faults there too:` +
335+
` guard it with \`previous != null && ${operand} != null\`.`;
336+
307337
/** Ordering + arithmetic operators — the ones with no `null` overload. */
308338
const FAULTING_BINARY_OPS = new Set(['<', '<=', '>', '>=', '+', '-', '*', '/', '%']);
309339

@@ -770,6 +800,7 @@ export function nullGuardMessage(
770800
`as nullable (no \`required: true\`, no \`defaultValue\`).${hasNote} At runtime the operand is ` +
771801
`null, CEL has no \`${finding.operator}\` overload for null, and the whole predicate aborts — ` +
772802
`${OUTCOME_CLAUSE[outcome]}. ` +
773-
`The predicate compares a value that is null. ${NULL_GUARD_HINT}`
803+
`The predicate compares a value that is null. ${NULL_GUARD_HINT}` +
804+
(finding.operand.startsWith('previous.') ? PREVIOUS_GUARD_CLAUSE(finding.operand) : '')
774805
);
775806
}

0 commit comments

Comments
 (0)