@@ -284,6 +284,79 @@ describe('#18682 — engine-produced relationship bindings', () => {
284284 } ) ;
285285 } ) ;
286286
287+ // [#24512] A `conditional` rule's `when` reading one hop through the lookup:
288+ // the engine preloads the hop for it and `checkConditional` evaluates `when`
289+ // on the related row, so the rule picks its branch instead of refusing every
290+ // write it guards as unevaluable.
291+ describe ( '[#24512] a `conditional` rule\'s `when` reads through the lookup' , ( ) => {
292+ const THEN = 'GATE-THEN: partner deals are capped at 10000.' ;
293+ const OTHERWISE = 'GATE-OTHERWISE: direct deals start at 100.' ;
294+ const gate = ( when : string , name = 'partner_gate' ) => ( {
295+ name, type : 'conditional' , severity : 'error' , message : 'unused' , when,
296+ then : { name : 'gate_cap' , type : 'script' , severity : 'error' , message : THEN , condition : 'record.amount > 10000' } ,
297+ otherwise : { name : 'gate_floor' , type : 'script' , severity : 'error' , message : OTHERWISE , condition : 'record.amount < 100' } ,
298+ } ) ;
299+ const register = ( validations : unknown [ ] ) => {
300+ engine . registry . registerObject ( {
301+ name : 'crm_opportunity' ,
302+ fields : {
303+ name : { type : 'text' } , amount : { type : 'number' } ,
304+ account : { type : 'lookup' , reference : 'crm_account' } ,
305+ } ,
306+ validations,
307+ } as any , 'test-package' ) ;
308+ d . calls . length = 0 ;
309+ } ;
310+ /** The rejection envelope, or `null` when the write is accepted. */
311+ const insert = async ( data : Record < string , unknown > ) : Promise < { code ?: string ; fields : any [ ] } | null > => {
312+ try {
313+ await engine . insert ( 'crm_opportunity' , data , { context : ACTING } as any ) ;
314+ return null ;
315+ } catch ( e ) {
316+ const err = e as { code ?: string ; fields ?: any [ ] } ;
317+ return { code : err . code , fields : err . fields ?? [ ] } ;
318+ }
319+ } ;
320+ const messagesOf = ( r : { fields : any [ ] } | null ) : string [ ] => ( r ?. fields ?? [ ] ) . map ( ( f ) => String ( f . message ) ) ;
321+
322+ it ( 'picks `then` for a partner account and `otherwise` for any other, on the real related row' , async ( ) => {
323+ register ( [ gate ( "record.account.type == 'partner'" ) ] ) ;
324+ const partner = await insert ( { name : 'A' , amount : 50000 , account : 'acc_p' } ) ;
325+ expect ( partner ?. code ) . toBe ( 'VALIDATION_FAILED' ) ;
326+ expect ( partner ?. fields ) . toEqual ( [ expect . objectContaining ( { code : 'rule_violation' , message : THEN } ) ] ) ;
327+ const direct = await insert ( { name : 'B' , amount : 50 , account : 'acc_d' } ) ;
328+ expect ( direct ?. code ) . toBe ( 'VALIDATION_FAILED' ) ;
329+ expect ( direct ?. fields ) . toEqual ( [ expect . objectContaining ( { code : 'rule_violation' , message : OTHERWISE } ) ] ) ;
330+ // Each branch admits what it does not refuse, and the other branch is not run.
331+ expect ( await insert ( { name : 'C' , amount : 50 , account : 'acc_p' } ) ) . toBeNull ( ) ;
332+ expect ( await insert ( { name : 'D' , amount : 50000 , account : 'acc_d' } ) ) . toBeNull ( ) ;
333+ } ) ;
334+
335+ it ( 'preloads the `when` hop: one system read, projecting only the column `when` names' , async ( ) => {
336+ register ( [ gate ( "record.account.type == 'partner'" ) ] ) ;
337+ await insert ( { name : 'A' , amount : 10 , account : 'acc_d' } ) ;
338+ expect ( relatedReads ( ) ) . toHaveLength ( 1 ) ;
339+ expect ( [ ...( relatedReads ( ) [ 0 ] . ast ?. fields ?? [ ] ) ] . sort ( ) ) . toEqual ( [ 'id' , 'type' ] ) ;
340+ } ) ;
341+
342+ it ( 'a `when` naming an undeclared related field refuses alone; a sibling `when` through the same lookup keeps its verdict' , async ( ) => {
343+ register ( [ gate ( "record.account.type == 'partner'" ) , gate ( "record.account.not_a_column == 'x'" , 'sneaky_gate' ) ] ) ;
344+ const direct = await insert ( { name : 'A' , amount : 50000 , account : 'acc_d' } ) ;
345+ expect ( direct ?. code ) . toBe ( 'VALIDATION_FAILED' ) ;
346+ expect ( direct ?. fields ) . toEqual ( [ expect . objectContaining ( {
347+ code : 'rule_violation' ,
348+ constraint : expect . objectContaining ( { rule : 'sneaky_gate' , reason : 'unevaluable' } ) ,
349+ } ) ] ) ;
350+ expect ( messagesOf ( direct ) [ 0 ] ) . toContain ( "'crm_account' declares no 'not_a_column'" ) ;
351+ // The valid gate, on a partner account, refuses with its own branch's message.
352+ const partner = await insert ( { name : 'B' , amount : 50000 , account : 'acc_p' } ) ;
353+ expect ( messagesOf ( partner ) ) . toContain ( THEN ) ;
354+ expect ( partner ?. fields . map ( ( f ) => f . constraint ?. rule ?? null ) ) . toEqual ( [ null , 'sneaky_gate' ] ) ;
355+ // The shared read never names the undeclared column.
356+ for ( const call of relatedReads ( ) ) expect ( call . ast ?. fields ?? [ ] ) . not . toContain ( 'not_a_column' ) ;
357+ } ) ;
358+ } ) ;
359+
287360 // The refusal is about the WRITE, never about the value. A caller can infer a
288361 // value by observing refusals — an accepted, deliberately narrow channel — so
289362 // nothing may make the refusal more informative than "this rule refused".
0 commit comments