@@ -29,6 +29,7 @@ describe('reference-integrity suite — membership', () => {
2929 'validateHookBodyWrites' ,
3030 'validateActionBodyWrites' ,
3131 'validateFlowNodeWrites' ,
32+ 'validateReadonlyFlowWrites' ,
3233 ] ) ;
3334 } ) ;
3435
@@ -51,7 +52,10 @@ describe('reference-integrity suite — every member actually runs', () => {
5152 objects : [
5253 {
5354 name : 'crm_lead' ,
54- fields : { name : { type : 'text' , label : 'Name' } } ,
55+ fields : {
56+ name : { type : 'text' , label : 'Name' } ,
57+ locked : { type : 'boolean' , label : 'Locked' , readonly : true } ,
58+ } ,
5559 // validateSearchableFields: `budget` is not a field on crm_lead, so the
5660 // ADR-0061 declaration is stale — the engine drops it and searches a
5761 // narrower set than the object declares.
@@ -157,6 +161,12 @@ describe('reference-integrity suite — every member actually runs', () => {
157161 {
158162 name : 'lead_followup' ,
159163 type : 'record_change' ,
164+ // validateReadonlyFlowWrites: a runAs:'user' update_record writing a
165+ // static-`readonly` field. The engine strips it and the step still
166+ // reports success (#2948/#3425) — a certainty, so it GATES. Walks the
167+ // same `config.fields` map validateFlowNodeWrites does, which is why
168+ // the two must not diverge across commands again.
169+ runAs : 'user' ,
160170 nodes : [
161171 { id : 'start' , type : 'start' , config : { objectName : 'crm_lead' , triggerType : 'record-created' } } ,
162172 // validateFlowTemplatePaths: `budget` is not a field on crm_lead. In a
@@ -178,6 +188,17 @@ describe('reference-integrity suite — every member actually runs', () => {
178188 type : 'update_record' ,
179189 config : { objectName : 'crm_lead' , fields : { lead_score : 100 } } ,
180190 } ,
191+ // validateReadonlyFlowWrites: the OTHER question about that same
192+ // `config.fields` map — `locked` exists but is static-`readonly`, so
193+ // under this flow's `runAs:'user'` the engine strips it and the step
194+ // still reports success (#2948/#3425). A separate node from `stamp`
195+ // on purpose: one node carrying both defects would let either rule
196+ // go silent behind the other's finding.
197+ {
198+ id : 'lock' ,
199+ type : 'update_record' ,
200+ config : { objectName : 'crm_lead' , fields : { locked : true } } ,
201+ } ,
181202 ] ,
182203 } ,
183204 ] ,
@@ -204,6 +225,7 @@ describe('reference-integrity suite — every member actually runs', () => {
204225 // why it rides along instead of becoming its own entry.
205226 expect ( rules ) . toContain ( 'action-record-write-discarded' ) ;
206227 expect ( rules ) . toContain ( 'flow-node-write-unknown-field' ) ;
228+ expect ( rules ) . toContain ( 'flow-update-readonly-field' ) ;
207229 } ) ;
208230
209231 it ( 'carries a gating flow-template finding through the suite (#3810)' , ( ) => {
@@ -225,9 +247,9 @@ describe('reference-integrity suite — every member actually runs', () => {
225247 expect ( typeof f . message ) . toBe ( 'string' ) ;
226248 expect ( typeof f . hint ) . toBe ( 'string' ) ;
227249 }
228- // Object references run first, flow-node writes last.
250+ // Object references run first, readonly flow writes last.
229251 expect ( findings [ 0 ] . rule ) . toBe ( 'object-reference-unknown' ) ;
230- expect ( findings [ findings . length - 1 ] . rule ) . toBe ( 'flow-node-write-unknown -field' ) ;
252+ expect ( findings [ findings . length - 1 ] . rule ) . toBe ( 'flow-update-readonly -field' ) ;
231253 } ) ;
232254
233255 it ( 'returns nothing for an empty stack' , ( ) => {
0 commit comments