@@ -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 = {
0 commit comments