Skip to content

Commit afacc31

Browse files
committed
feat(lint): the null-guard gate judges a record-triggered flow's start, edge and decision conditions
A record-triggered flow's record and previous are total over the trigger object's declared fields, so an ordering or arithmetic comparison on a nullable field faults on the first empty value and the run fails there. The gate now covers the start condition, every edge condition and every decision branch expression of such a flow, judges bare flattened field names as record.<f> when the flow's bound set is provable and no variable or node id holds the name, and presumes what the start condition proves at every condition downstream. A count or sum roll-up summary is always valued (zero children is zero) and no longer counts as nullable. Claude-Session: https://claude.ai/code/session_01JQwxbZfKtKX5FguJoubPfK Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7f277a8 commit afacc31

6 files changed

Lines changed: 630 additions & 121 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
feat(lint): the publish-time null-guard gate judges a record-triggered flow's start, edge and decision-branch conditions
6+
7+
Clause-②: no
8+
Metadata-protocol: narrowed
9+
10+
`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).
11+
12+
- **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.
13+
- **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.
14+
- **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`.
15+
- **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.
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #29026 — the null-guard gate on a record-triggered flow's start, edge and
4+
// `decision` branch conditions. `validate-null-guards.test.ts` pins the
5+
// decision procedure; this file pins the wiring: which flows, which slots,
6+
// which bare names, and what the start condition proves downstream. The
7+
// ledger row in `validate-null-guards.ts` carries the runtime measurements.
8+
9+
import { describe, it, expect } from 'vitest';
10+
11+
import { validateStackExpressions } from './validate-expressions.js';
12+
13+
// `budget` and `owner` are optional, as on `showcase_project`; `name` is
14+
// required and `spent` has a default, so neither may ever be flagged.
15+
const project = {
16+
name: 'showcase_project',
17+
fields: {
18+
name: { type: 'text', required: true },
19+
owner: { type: 'text' },
20+
health: { type: 'select', options: [{ value: 'green', default: true }, { value: 'red' }] },
21+
budget: { type: 'currency' },
22+
spent: { type: 'currency', defaultValue: 0 },
23+
},
24+
};
25+
26+
type Rec = Record<string, unknown>;
27+
28+
const start = (config: Rec = {}): Rec => ({
29+
id: 'start',
30+
type: 'start',
31+
config: { objectName: 'showcase_project', triggerType: 'record-after-update', ...config },
32+
});
33+
34+
const flow = (overrides: Rec): Rec => ({
35+
name: 'escalate',
36+
label: 'Escalate',
37+
type: 'autolaunched',
38+
nodes: [start(), { id: 'triage', type: 'decision' }, { id: 'alert', type: 'end' }, { id: 'calm', type: 'end' }],
39+
edges: [
40+
{ id: 'e1', source: 'start', target: 'triage' },
41+
{ id: 'e2', source: 'triage', target: 'alert', condition: 'budget > 200000' },
42+
{ id: 'e3', source: 'triage', target: 'calm', isDefault: true },
43+
],
44+
...overrides,
45+
});
46+
47+
/** The null-guard findings alone — other passes judge other things about these flows. */
48+
const nullGuardIssues = (flows: Rec[]) =>
49+
validateStackExpressions({ objects: [project], flows }).filter((i) => i.message.includes('declares as nullable'));
50+
51+
describe('flow conditions under the null-guard gate (#29026)', () => {
52+
it('rejects the bare unguarded comparison that failed the showcase escalation run', () => {
53+
const issues = nullGuardIssues([flow({})]);
54+
expect(issues).toHaveLength(1);
55+
const [issue] = issues;
56+
expect(issue!.severity).toBe('error');
57+
expect(issue!.where).toContain("edge 'e2'");
58+
expect(issue!.path).toBe('flows[0].edges[1].condition');
59+
expect(issue!.message).toContain('`budget`');
60+
expect(issue!.message).toContain('the flow run fails at this condition');
61+
expect(issue!.message).toContain("Guard it with '!= null'");
62+
});
63+
64+
it('rejects the `record.` spelling too, and accepts either guarded spelling', () => {
65+
const edges = (condition: string) => [
66+
{ id: 'e1', source: 'start', target: 'triage' },
67+
{ id: 'e2', source: 'triage', target: 'alert', condition },
68+
{ id: 'e3', source: 'triage', target: 'calm', isDefault: true },
69+
];
70+
expect(nullGuardIssues([flow({ edges: edges('record.budget > 200000') })])).toHaveLength(1);
71+
expect(nullGuardIssues([flow({ edges: edges('record.budget != null && record.budget > 200000') })])).toEqual([]);
72+
expect(nullGuardIssues([flow({ edges: edges('budget != null && budget > 200000') })])).toEqual([]);
73+
// `has()` is true on the materialized null, so it is no guard here either.
74+
const hasOnly = nullGuardIssues([flow({ edges: edges('has(record.budget) && record.budget > 200000') })]);
75+
expect(hasOnly).toHaveLength(1);
76+
expect(hasOnly[0]!.message).toContain('`has(record.budget)` does not guard it');
77+
});
78+
79+
it('judges the start condition, and presumes what it proves at every edge downstream', () => {
80+
const unguardedStart = nullGuardIssues([flow({
81+
nodes: [start({ condition: 'health == "red" && budget > 100000' }), { id: 'triage', type: 'decision' }, { id: 'alert', type: 'end' }, { id: 'calm', type: 'end' }],
82+
edges: [{ id: 'e1', source: 'start', target: 'triage' }],
83+
})]);
84+
expect(unguardedStart).toHaveLength(1);
85+
expect(unguardedStart[0]!.where).toContain("node 'start' (start) condition");
86+
expect(unguardedStart[0]!.path).toBe('flows[0].nodes[0].config.condition');
87+
88+
// The start condition gates every run, so its guard holds at e2.
89+
expect(nullGuardIssues([flow({
90+
nodes: [start({ condition: 'budget != null && budget > 100000' }), { id: 'triage', type: 'decision' }, { id: 'alert', type: 'end' }, { id: 'calm', type: 'end' }],
91+
})])).toEqual([]);
92+
});
93+
94+
it('judges a `decision` branch expression', () => {
95+
const issues = nullGuardIssues([flow({
96+
nodes: [
97+
start(),
98+
{ id: 'triage', type: 'decision', config: { conditions: [{ label: 'critical', expression: 'record.budget > 200000' }, { label: 'normal', expression: 'true' }] } },
99+
{ id: 'alert', type: 'end' },
100+
{ id: 'calm', type: 'end' },
101+
],
102+
edges: [{ id: 'e1', source: 'start', target: 'triage' }],
103+
})]);
104+
expect(issues).toHaveLength(1);
105+
expect(issues[0]!.where).toContain('conditions[0].expression');
106+
});
107+
108+
it('judges an edge inside a container region', () => {
109+
const issues = nullGuardIssues([flow({
110+
nodes: [
111+
start(),
112+
{
113+
id: 'fan',
114+
type: 'parallel',
115+
config: {
116+
branches: [{
117+
name: 'Owner',
118+
nodes: [{ id: 'gate', type: 'decision' }, { id: 'ping', type: 'end' }],
119+
edges: [{ id: 'b1', source: 'gate', target: 'ping', condition: 'record.budget * 2 > 10' }],
120+
}],
121+
},
122+
},
123+
],
124+
edges: [{ id: 'e1', source: 'start', target: 'fan' }],
125+
})]);
126+
expect(issues).toHaveLength(1);
127+
expect(issues[0]!.where).toContain("edge 'b1'");
128+
});
129+
130+
it('leaves a flow with no record trigger alone: nothing makes its record total', () => {
131+
const noTrigger = flow({ nodes: [{ id: 'start', type: 'start', config: { objectName: 'showcase_project' } }, { id: 'triage', type: 'decision' }, { id: 'alert', type: 'end' }, { id: 'calm', type: 'end' }] });
132+
expect(nullGuardIssues([noTrigger])).toEqual([]);
133+
const api = flow({ type: 'api', nodes: [{ id: 'start', type: 'start', config: { objectName: 'showcase_project', triggerType: 'api' } }, { id: 'triage', type: 'decision' }, { id: 'alert', type: 'end' }, { id: 'calm', type: 'end' }] });
134+
expect(nullGuardIssues([api])).toEqual([]);
135+
});
136+
137+
it('leaves a bare name a declared variable holds alone — the variable wins — and still judges `record.`', () => {
138+
const variables = [{ name: 'budget', type: 'number', defaultValue: 5 }];
139+
expect(nullGuardIssues([flow({ variables })])).toEqual([]);
140+
const issues = nullGuardIssues([flow({
141+
variables,
142+
edges: [
143+
{ id: 'e1', source: 'start', target: 'triage' },
144+
{ id: 'e2', source: 'triage', target: 'alert', condition: 'record.budget > 200000' },
145+
{ id: 'e3', source: 'triage', target: 'calm', isDefault: true },
146+
],
147+
})]);
148+
expect(issues).toHaveLength(1);
149+
});
150+
151+
it('leaves bare names alone on a flow whose nodes can bind names nobody declared', () => {
152+
const withScript = (condition: string) => flow({
153+
nodes: [start(), { id: 'calc', type: 'script', config: { function: 'calc' } }, { id: 'triage', type: 'decision' }, { id: 'alert', type: 'end' }, { id: 'calm', type: 'end' }],
154+
edges: [
155+
{ id: 'e0', source: 'start', target: 'calc' },
156+
{ id: 'e1', source: 'calc', target: 'triage' },
157+
{ id: 'e2', source: 'triage', target: 'alert', condition },
158+
{ id: 'e3', source: 'triage', target: 'calm', isDefault: true },
159+
],
160+
});
161+
expect(nullGuardIssues([withScript('budget > 200000')])).toEqual([]);
162+
expect(nullGuardIssues([withScript('record.budget > 200000')])).toHaveLength(1);
163+
});
164+
165+
it('does not judge an inert `config.condition` on a non-start node', () => {
166+
expect(nullGuardIssues([flow({
167+
nodes: [start(), { id: 'triage', type: 'decision', config: { condition: 'record.budget > 1' } }, { id: 'alert', type: 'end' }, { id: 'calm', type: 'end' }],
168+
edges: [{ id: 'e1', source: 'start', target: 'triage' }],
169+
})])).toEqual([]);
170+
});
171+
172+
it('treats a `count` / `sum` roll-up as always valued, and a `max` one as nullable', () => {
173+
// Zero children is zero for `count` / `sum` (objectql's
174+
// `summaryEmptySetValue`); `min` / `max` / `avg` over no rows is `null`.
175+
const report = {
176+
name: 'expense_report',
177+
fields: {
178+
status: { type: 'select', options: [{ value: 'draft', default: true }, { value: 'submitted' }] },
179+
total_amount: { type: 'summary', summaryOperations: { object: 'expense_line', field: 'amount', function: 'sum' } },
180+
line_count: { type: 'summary', summaryOperations: { object: 'expense_line', function: 'count' } },
181+
largest_line: { type: 'summary', summaryOperations: { object: 'expense_line', field: 'amount', function: 'max' } },
182+
},
183+
};
184+
const quorum = (condition: string): Rec => ({
185+
name: 'quorum',
186+
label: 'Quorum',
187+
type: 'autolaunched',
188+
nodes: [{ id: 'start', type: 'start', config: { objectName: 'expense_report', triggerType: 'record-after-update', condition } }, { id: 'end', type: 'end' }],
189+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
190+
});
191+
const judge = (condition: string) =>
192+
validateStackExpressions({ objects: [report], flows: [quorum(condition)] })
193+
.filter((i) => i.message.includes('declares as nullable'));
194+
expect(judge('status == "submitted" && total_amount >= 5000 && line_count > 1')).toEqual([]);
195+
expect(judge('largest_line > 1000').map((i) => i.message)).toEqual([expect.stringContaining('`largest_line`')]);
196+
});
197+
198+
it('never flags a non-nullable field', () => {
199+
expect(nullGuardIssues([flow({
200+
edges: [{ id: 'e1', source: 'start', target: 'triage' }, { id: 'e2', source: 'triage', target: 'alert', condition: 'spent > 5 && size(name) > 1' }],
201+
})])).toEqual([]);
202+
});
203+
});

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

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,11 @@ import type { ExprIssue } from './validate-expressions.js';
3737
import { validateSemanticRoles, SEMANTIC_ROLE_FIELD_UNPROVISIONED } from './validate-semantic-roles.js';
3838

3939
describe('validateStackExpressions (ADR-0032 build-time)', () => {
40+
// [#29026] `rating` is required: a record-triggered flow comparing an
41+
// optional field with no `!= null` in front is refused by the null-guard
42+
// gate, and this suite's flows compare `rating` to pin other verdicts.
4043
const objects = [
41-
{ name: 'crm_lead', fields: { rating: { type: 'number' }, status: { type: 'text' } } },
44+
{ name: 'crm_lead', fields: { rating: { type: 'number', required: true }, status: { type: 'text' } } },
4245
];
4346

4447
it('passes a clean stack', () => {
@@ -3207,28 +3210,30 @@ describe('null-guard gate (#4763)', () => {
32073210
).toHaveLength(0);
32083211
});
32093212

3210-
// #4811 re-measured the reason this one is excluded. It is NOT the
3211-
// flattened scope (this gate never resolves a bare identifier — only
3212-
// `record.<f>`/`previous.<f>`, and a record-triggered run binds both
3213-
// roots — #22677). It is that `record-change-trigger.ts` seeds the flow's
3214-
// record as `{ ...(inputData ?? {}), ...after }` (spelled `inputDoc` until
3215-
// #5671 dropped that alias read) with no `materializeDeclaredFields`,
3216-
// so a declared column the write never mentioned is an ABSENT key — and on
3217-
// an absent key the `!= null` this gate prescribes faults exactly like the
3218-
// comparison it was meant to guard.
3219-
it('leaves flow conditions alone (trigger record is not total over declared fields)', () => {
3213+
// RE-JUDGED IN PLACE (#29026). This pinned `toHaveLength(0)` while the
3214+
// record-change trigger seeded the flow's record sparse; since #4953 it
3215+
// materializes every declared field, so a record-triggered flow's edge is
3216+
// covered and `validate-expressions.flow-null-guards.test.ts` pins the
3217+
// wiring. What stays out is pinned here: a non-start node's
3218+
// `config.condition` (inert — nothing evaluates it), and a flow with no
3219+
// record trigger, whose record nothing makes total.
3220+
it('judges a record-triggered flow edge, and leaves an inert node condition and an untriggered flow alone', () => {
3221+
const flow = (triggerType?: string) => ({
3222+
name: 'escalate',
3223+
nodes: [
3224+
{ id: 'start', type: 'start', config: { objectName: 'showcase_project', ...(triggerType ? { triggerType } : {}) } },
3225+
{ id: 'd', type: 'decision', config: { condition: 'record.budget > 100000' } },
3226+
],
3227+
edges: [{ id: 'e1', source: 'd', target: 'end', condition: 'record.spent > record.budget' }],
3228+
});
3229+
const triggered = validateStackExpressions({ objects: [project], flows: [flow('record-after-update')] });
3230+
expect(triggered.map((i) => i.where)).toEqual([expect.stringContaining("edge 'e1'")]);
3231+
expect(triggered[0]!.message).toContain('`record.budget`');
3232+
// (With no trigger the run binds no `record` at all, which the flow CEL
3233+
// root judge reports; that is its verdict, not this gate's.)
32203234
expect(
3221-
validateStackExpressions({
3222-
objects: [project],
3223-
flows: [{
3224-
name: 'escalate',
3225-
nodes: [
3226-
{ id: 'start', type: 'start', config: { objectName: 'showcase_project', triggerType: 'record-after-update' } },
3227-
{ id: 'd', type: 'decision', config: { condition: 'record.budget > 100000' } },
3228-
],
3229-
edges: [{ id: 'e1', source: 'd', target: 'end', condition: 'record.spent > record.budget' }],
3230-
}],
3231-
}),
3235+
validateStackExpressions({ objects: [project], flows: [flow()] })
3236+
.filter((i) => i.message.includes('declares as nullable')),
32323237
).toHaveLength(0);
32333238
});
32343239

@@ -3435,10 +3440,20 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
34353440
{
34363441
receiver: 'def',
34373442
// `referenceTo` is absent — likewise.
3438-
expected: ['defaultValue', 'options', 'reference', 'required', 'type'],
3443+
// [#29026] `summaryOperations` joined: a `count` / `sum` roll-up is
3444+
// always valued for the null-guard gate, and its function is read below.
3445+
expected: ['defaultValue', 'options', 'reference', 'required', 'summaryOperations', 'type'],
34393446
declaredBy: 'FieldSchema',
34403447
keys: () => Object.keys(FieldSchema.shape),
34413448
},
3449+
{
3450+
// [#29026] The roll-up's aggregate function — the null-guard gate's
3451+
// nullability judgment reads it and nothing else here.
3452+
receiver: 'summaryOperations',
3453+
expected: ['function'],
3454+
declaredBy: 'FieldSchema.summaryOperations',
3455+
keys: () => shapeKeysOf(FieldSchema.shape.summaryOperations),
3456+
},
34423457
{
34433458
// [#24798] One `bulkActionDefs[]` entry: its `confirmText` template slot,
34443459
// and its `name` for the location (its params are read by the spec's
@@ -3712,6 +3727,11 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t
37123727
// node walk's own sites. Its keys are `Map.prototype.set` / `get`, never
37133728
// metadata keys; the config it describes is read off the tabled `cfg`.
37143729
'nodeSlotKeys',
3730+
// [#29026] The flow null-guard wiring: the flow CEL root judge's own
3731+
// result (`provable`), the flow's declared-name set and the gate's
3732+
// bare-name set. Their keys are that module's result key and Set
3733+
// methods, never metadata keys.
3734+
'rootScope', 'declaredVariables', 'nullGuardBare',
37153735
]);
37163736
expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]);
37173737
});
@@ -4618,8 +4638,10 @@ describe('assignment value envelope — located findings (#15137)', () => {
46184638
* controls below are those two shapes staying green.
46194639
*/
46204640
describe('structural condition shape (#15662)', () => {
4641+
// [#29026] `rating` is required so the record-triggered null-guard gate
4642+
// stays out of the shape verdicts this suite pins (its controls compare it).
46214643
const objects = [
4622-
{ name: 'crm_lead', fields: { rating: { type: 'number' }, status: { type: 'text' } } },
4644+
{ name: 'crm_lead', fields: { rating: { type: 'number', required: true }, status: { type: 'text' } } },
46234645
];
46244646

46254647
const flowWith = (opts: { startCondition?: unknown; decisionCondition?: unknown; edgeCondition?: unknown }) => ({
@@ -4785,8 +4807,10 @@ describe('structural condition shape (#15662)', () => {
47854807
* one shared refusal to prevent.
47864808
*/
47874809
describe('blank structural condition (#17495)', () => {
4810+
// [#29026] `rating` is required so the record-triggered null-guard gate
4811+
// stays out of the shape verdicts this suite pins (its controls compare it).
47884812
const objects = [
4789-
{ name: 'crm_lead', fields: { rating: { type: 'number' }, status: { type: 'text' } } },
4813+
{ name: 'crm_lead', fields: { rating: { type: 'number', required: true }, status: { type: 'text' } } },
47904814
];
47914815

47924816
const flowWith = (opts: { startCondition?: unknown; decisionCondition?: unknown; edgeCondition?: unknown }) => ({

0 commit comments

Comments
 (0)