Skip to content

Commit 5c28b88

Browse files
claude[bot]claude
andauthored
fix(lint): inspect runAs:'system' flows on the readonlyWhen branch (#14370)
The runAs:'system' exemption in validate-readonly-flow-writes was a single flow-level early return, removing an elevated flow from BOTH branches of the rule. Only the static branch warrants it: the engine skips stripReadonlyFields under `if (!opCtx.context?.isSystem)`, while stripReadonlyWhenFields runs on the update path with no isSystem guard at all. The exemption now gates the static branch only, so a system flow whose update_record node writes a readonlyWhen field reports the conditional branch's existing warning. Rule ids and severities are untouched. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8d3f093 commit 5c28b88

3 files changed

Lines changed: 172 additions & 19 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
lint: `flow-update-readonly-when-field` now inspects `runAs:'system'` flows
6+
7+
The `runAs:'system'` exemption in `validate-readonly-flow-writes` was a single
8+
flow-level early return, so it removed an elevated flow from **both** branches of
9+
the rule. Only the static branch warrants it: the engine skips
10+
`stripReadonlyFields` under `if (!opCtx.context?.isSystem)`, but
11+
`stripReadonlyWhenFields` runs on the update path with no `isSystem` guard at all
12+
(`packages/objectql/src/engine.ts`, the #9107 note: "`isSystem` is still NOT an
13+
exemption here, unlike the static strip below"), pinned as "LOCK 2 — isSystem does
14+
NOT exempt a caller-supplied value".
15+
16+
The exemption now gates the static branch only. A `runAs:'system'` flow whose
17+
`update_record` node writes a `readonlyWhen` field reports the branch's existing
18+
`warning` — the same silent-no-op the rule exists to surface, on the flow class the
19+
rule's own hint tells the author elevation cannot save. A system flow writing a
20+
static `readonly:true` field stays silent, as before; rule ids and severities are
21+
unchanged, and the new finding is advisory and never blocks a build.

packages/lint/src/validate-readonly-flow-writes.test.ts

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,14 +197,133 @@ describe('validateReadonlyFlowWrites', () => {
197197
});
198198

199199
// ── clean: runAs:system is the intended maintenance channel ───────────
200-
it('does NOT flag a runAs:system flow (elevated writer bypasses the strip)', () => {
200+
// …for the STATIC strip, and only for it. The engine skips
201+
// `stripReadonlyFields` under `if (!opCtx.context?.isSystem)`, so an elevated
202+
// flow maintaining a `readonly:true` column is the intended channel and stays
203+
// silent. Paired with the `readonlyWhen` case below, which is the OTHER half
204+
// of the same run identity — the two must not move together (#14201).
205+
it('does NOT flag a runAs:system flow writing a STATIC readonly field (elevated writer bypasses that strip)', () => {
201206
const findings = validateReadonlyFlowWrites({
202207
objects: [opportunityObject],
203208
flows: [flowWith({ approval_status: 'approved' }, { runAs: 'system' })],
204209
});
205210
expect(findings).toEqual([]);
206211
});
207212

213+
// ── runAs:system + readonlyWhen → still a WARNING (#14201) ────────────
214+
// `stripReadonlyWhenFields` is called on the update path with NO `isSystem`
215+
// guard at all (engine.ts, the #9107 note: "`isSystem` is still NOT an
216+
// exemption here, unlike the static strip below"), pinned from both sides as
217+
// "LOCK 2 — isSystem does NOT exempt a caller-supplied value"
218+
// (`engine-readonly-when-derived-writes.test.ts`) and "covers readonlyWhen
219+
// too — the arm a trusted (isSystem) caller can still hit"
220+
// (`engine-readonly-strict-writes.test.ts`). So the elevated flow's write
221+
// vanishes on a locked record exactly as a user run's does, and the rule that
222+
// exists to surface that silent no-op has to say so on the very flow class
223+
// its own hint tells the author elevation cannot save.
224+
it('warns when a runAs:system flow writes a readonlyWhen field (elevation does NOT waive the conditional strip)', () => {
225+
const findings = validateReadonlyFlowWrites({
226+
objects: [opportunityObject],
227+
flows: [flowWith({ amount: 5000 }, { runAs: 'system' })],
228+
});
229+
expect(findings).toHaveLength(1);
230+
expect(findings[0].severity).toBe('warning');
231+
expect(findings[0].rule).toBe(FLOW_UPDATE_READONLY_WHEN_FIELD);
232+
expect(findings[0].path).toBe('flows[0].nodes[1].config.fields.amount');
233+
// The message states the run identity it was judged under, so a reader of
234+
// the finding cannot mistake it for the user-run case.
235+
expect(findings[0].message).toContain("runAs:'system'");
236+
expect(findings[0].message).toContain('#3042');
237+
expect(findings[0].hint).toContain('NOT waived by a system context');
238+
});
239+
240+
it('reports ONLY the conditional half for a runAs:system node writing both kinds in one payload', () => {
241+
const findings = validateReadonlyFlowWrites({
242+
objects: [opportunityObject],
243+
flows: [flowWith({ approval_status: 'approved', amount: 5000, notes: 'hi' }, { runAs: 'system' })],
244+
});
245+
expect(findings).toHaveLength(1);
246+
expect(findings[0].severity).toBe('warning');
247+
expect(findings[0].path).toBe('flows[0].nodes[1].config.fields.amount');
248+
expect(findings.some((f) => f.rule === FLOW_UPDATE_READONLY_FIELD)).toBe(false);
249+
});
250+
251+
// A field declaring BOTH flags: under `runAs:'system'` the static strip is
252+
// skipped and the conditional one is not, so the truthful finding is the
253+
// warning — not silence (the old flow-level skip) and not the error (which
254+
// would state something false about an elevated write).
255+
it('falls through to the conditional branch for a field declaring readonly AND readonlyWhen under runAs:system', () => {
256+
const bothFlags = {
257+
name: 'crm_opportunity',
258+
fields: {
259+
approval_status: { type: 'text', readonly: true, readonlyWhen: "record.stage == 'closed_won'" },
260+
},
261+
};
262+
const systemFindings = validateReadonlyFlowWrites({
263+
objects: [bothFlags],
264+
flows: [flowWith({ approval_status: 'approved' }, { runAs: 'system' })],
265+
});
266+
expect(systemFindings).toHaveLength(1);
267+
expect(systemFindings[0].severity).toBe('warning');
268+
expect(systemFindings[0].rule).toBe(FLOW_UPDATE_READONLY_WHEN_FIELD);
269+
270+
// Unchanged for a user run: the static strip applies there, and the certain
271+
// no-op outranks the conditional one.
272+
const userFindings = validateReadonlyFlowWrites({
273+
objects: [bothFlags],
274+
flows: [flowWith({ approval_status: 'approved' }, { runAs: 'user' })],
275+
});
276+
expect(userFindings).toHaveLength(1);
277+
expect(userFindings[0].severity).toBe('error');
278+
expect(userFindings[0].rule).toBe(FLOW_UPDATE_READONLY_FIELD);
279+
});
280+
281+
// Nesting is orthogonal to run identity: the walk reaches an elevated flow's
282+
// nested regions on the conditional branch too.
283+
it('reaches a readonlyWhen write nested in a loop body under runAs:system', () => {
284+
const flow = {
285+
name: 'sweep_system',
286+
runAs: 'system',
287+
nodes: [
288+
{
289+
id: 'each',
290+
type: 'loop',
291+
label: 'Each',
292+
config: {
293+
collection: '{items}',
294+
body: {
295+
nodes: [
296+
{ id: 'u', type: 'update_record', label: 'U', config: { objectName: 'crm_opportunity', fields: { amount: 1 } } },
297+
],
298+
edges: [],
299+
},
300+
},
301+
},
302+
],
303+
edges: [],
304+
};
305+
const findings = validateReadonlyFlowWrites({ objects: [opportunityObject], flows: [flow] });
306+
expect(findings).toHaveLength(1);
307+
expect(findings[0].severity).toBe('warning');
308+
expect(findings[0].path).toBe('flows[0].nodes[0].config.body.nodes[0].config.fields.amount');
309+
});
310+
311+
// create_record stays exempt on BOTH branches under elevation: a
312+
// `readonlyWhen` predicate has no prior record to evaluate on an insert.
313+
it('does NOT flag create_record writing a readonlyWhen field under runAs:system', () => {
314+
const flow = {
315+
name: 'seed_opp_system',
316+
type: 'record_change',
317+
runAs: 'system',
318+
nodes: [
319+
{ id: 'start', type: 'start', config: {} },
320+
{ id: 'c', type: 'create_record', label: 'Create', config: { objectName: 'crm_opportunity', fields: { amount: 10 } } },
321+
],
322+
edges: [],
323+
};
324+
expect(validateReadonlyFlowWrites({ objects: [opportunityObject], flows: [flow] })).toEqual([]);
325+
});
326+
208327
// ── clean: create_record is engine-exempt from the readonly strip ─────
209328
it('does NOT flag create_record writing a readonly field', () => {
210329
const flow = {

packages/lint/src/validate-readonly-flow-writes.ts

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,26 @@
1717
// by calling the data engine directly), so a create writing a readonly
1818
// field is NOT a no-op and is never flagged.
1919
//
20-
// • Only `runAs !== 'system'`. A `runAs:'system'` run is elevated and the
21-
// engine skips the STATIC `readonly` strip, so a system flow legitimately
22-
// MAINTAINS readonly fields ("users can't edit this, but automation does").
23-
// That is the intended channel, so it is never flagged.
20+
// • `runAs:'system'` exempts the STATIC branch ONLY - it is not a flow-level
21+
// skip. An elevated run bypasses the static `readonly` strip, so a system
22+
// flow legitimately MAINTAINS readonly fields ("users can't edit this, but
23+
// automation does"). That is the intended channel, so it is never flagged.
2424
//
25-
// ⚠️ That exemption is the STATIC strip's alone. `stripReadonlyWhenFields`
26-
// runs with no `isSystem` guard at all (engine.ts, the #9107 note: "`isSystem`
27-
// is still NOT an exemption here, unlike the static strip below"), pinned as
28-
// "LOCK 2 - isSystem does NOT exempt a caller-supplied value" in
29-
// `engine-readonly-when-derived-writes.test.ts`. So elevation is NOT a
30-
// `readonlyWhen` remedy, and this rule's hint must never offer it. The skip
31-
// above is therefore WIDER than the conditional lock warrants - a
32-
// `runAs:'system'` flow writing a `readonlyWhen` field is still stripped on a
33-
// locked record and goes unflagged. Left as-is deliberately: the match set is
34-
// out of scope for the message-text correction that fixed the hint.
25+
// ⚠️ The exemption stops there. `stripReadonlyWhenFields` runs with no
26+
// `isSystem` guard at all (engine.ts, the #9107 note: "`isSystem` is still
27+
// NOT an exemption here, unlike the static strip below"), pinned as "LOCK 2
28+
// - isSystem does NOT exempt a caller-supplied value" in
29+
// `engine-readonly-when-derived-writes.test.ts` and from the other side in
30+
// `engine-readonly-strict-writes.test.ts` ("covers readonlyWhen too - the
31+
// arm a trusted (isSystem) caller can still hit"). So a `runAs:'system'`
32+
// flow writing a `readonlyWhen` field IS still stripped on a locked record,
33+
// and the conditional branch inspects an elevated flow exactly like any
34+
// other, at its usual `warning` severity. Narrowing this exemption to the
35+
// branch it belongs to (#14201) is what stops the rule from going silent on
36+
// the one flow class its own hint tells the author elevation cannot save -
37+
// the same split the action sibling was born with
38+
// (`validate-readonly-action-writes.ts`: an action body is system-elevated
39+
// BY DESIGN, so it carries the conditional half and only that half).
3540
//
3641
// • Static `readonly:true` + a LITERAL field name is a 100%-certain no-op →
3742
// ERROR (gates the build). `readonlyWhen` is per-record-state — it strips
@@ -150,10 +155,13 @@ export function validateReadonlyFlowWrites(stack: AnyRec): ReadonlyFlowWriteFind
150155

151156
flows.forEach((flow, flowIndex) => {
152157
// `runAs` defaults to 'user' (schema default). Only an explicit 'system'
153-
// run bypasses the strip, so treat anything else — including an unauthored
154-
// (undefined) runAs — as strip-subject.
155-
if (flow.runAs === 'system') return;
158+
// run bypasses the STATIC strip, so treat anything else — including an
159+
// unauthored (undefined) runAs — as subject to both strips. ⛔ Not a
160+
// flow-level skip: the conditional strip has no `isSystem` guard, so an
161+
// elevated flow stays in the walk and is judged on the `readonlyWhen`
162+
// branch below (#14201).
156163
const runAs = flow.runAs === 'user' || flow.runAs === 'system' ? flow.runAs : 'user';
164+
const isSystemRun = runAs === 'system';
157165

158166
const flowName = typeof flow.name === 'string' ? flow.name : `#${flowIndex}`;
159167
// Every node, INCLUDING those nested in try_catch / loop / parallel regions.
@@ -189,7 +197,12 @@ export function validateReadonlyFlowWrites(stack: AnyRec): ReadonlyFlowWriteFind
189197
// the two never double-report the same key.
190198
if (!meta) continue;
191199

192-
if (meta.readonly) {
200+
// The static branch is the one an elevated run really does bypass, so
201+
// `isSystem` gates it HERE rather than at flow level. A field declaring
202+
// BOTH flags therefore still falls through to the conditional branch
203+
// under `runAs:'system'` — which is the truth about that write: the
204+
// static strip is skipped, the conditional one is not.
205+
if (meta.readonly && !isSystemRun) {
193206
findings.push({
194207
severity: 'error',
195208
rule: FLOW_UPDATE_READONLY_FIELD,

0 commit comments

Comments
 (0)