@@ -40,7 +40,7 @@ class DecisionService
4040
4141 Decision = Struct . new ( :experiment , :variation , :source , :cmab_uuid )
4242 CmabDecisionResult = Struct . new ( :error , :result , :reasons )
43- VariationResult = Struct . new ( :cmab_uuid , :error , :reasons , :variation_id )
43+ VariationResult = Struct . new ( :cmab_uuid , :error , :reasons , :variation_id , :holdout_decision )
4444 DecisionResult = Struct . new ( :decision , :error , :reasons )
4545
4646 DECISION_SOURCES = {
@@ -169,7 +169,7 @@ def get_variation_for_feature(project_config, feature_flag, user_context, decide
169169 # user_context - Optimizely user context instance
170170 #
171171 # Returns DecisionResult struct.
172- # Get running holdouts from the holdout_id_map (all holdouts are global now )
172+ # Check for any running holdouts ( global or local )
173173 running_holdouts = project_config . holdout_id_map . values
174174
175175 if running_holdouts && !running_holdouts . empty?
@@ -196,8 +196,8 @@ def get_decision_for_flag(feature_flag, user_context, project_config, decide_opt
196196 reasons = decide_reasons ? decide_reasons . dup : [ ]
197197 user_id = user_context . user_id
198198
199- # Check holdouts (all holdouts are global now - apply to all flags)
200- holdouts = project_config . holdout_id_map . values
199+ # Check global holdouts first (flag level) — these apply to all rules across all flags
200+ holdouts = project_config . global_holdouts
201201
202202 holdouts . each do |holdout |
203203 holdout_decision = get_variation_for_holdout ( holdout , user_context , project_config )
@@ -365,6 +365,9 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_cont
365365 # If there's an error, return immediately instead of falling back to next experiment
366366 return DecisionResult . new ( nil , error , decide_reasons ) if error
367367
368+ # If a global holdout decision was made, return it directly
369+ return DecisionResult . new ( variation_result . holdout_decision , false , decide_reasons ) if variation_result . holdout_decision
370+
368371 next unless variation_id
369372
370373 variation = project_config . get_variation_from_id_by_experiment_id ( experiment_id , variation_id )
@@ -415,8 +418,11 @@ def get_variation_for_feature_rollout(project_config, feature_flag, user_context
415418 index = 0
416419 rollout_rules = rollout [ 'experiments' ]
417420 while index < rollout_rules . length
418- variation , skip_to_everyone_else , reasons_received = get_variation_from_delivery_rule ( project_config , feature_flag_key , rollout_rules , index , user_context )
421+ holdout_decision , variation , skip_to_everyone_else , reasons_received = get_variation_from_delivery_rule ( project_config , feature_flag_key , rollout_rules , index , user_context )
419422 decide_reasons . push ( *reasons_received )
423+
424+ return DecisionResult . new ( holdout_decision , false , decide_reasons ) if holdout_decision
425+
420426 if variation
421427 rule = rollout_rules [ index ]
422428 feature_decision = Decision . new ( rule , variation , DECISION_SOURCES [ 'ROLLOUT' ] , nil )
@@ -441,11 +447,24 @@ def get_variation_from_experiment_rule(project_config, flag_key, rule, user, use
441447 # Returns variation_id and reasons
442448 reasons = [ ]
443449
450+ # Step 1: Forced decision check
444451 context = Optimizely ::OptimizelyUserContext ::OptimizelyDecisionContext . new ( flag_key , rule [ 'key' ] )
445452 variation , forced_reasons = validated_forced_decision ( project_config , context , user )
446453 reasons . push ( *forced_reasons )
447454 return VariationResult . new ( nil , false , reasons , variation [ 'id' ] ) if variation
448455
456+ # Step 2: Local holdout check
457+ local_holdouts = project_config . get_holdouts_for_rule ( rule [ 'id' ] )
458+ local_holdouts . each do |holdout |
459+ holdout_decision = get_variation_for_holdout ( holdout , user , project_config )
460+ reasons . push ( *holdout_decision . reasons )
461+ next unless holdout_decision . decision
462+
463+ holdout_variation = holdout_decision . decision . variation
464+ return VariationResult . new ( nil , false , reasons , holdout_variation [ 'id' ] , holdout_decision . decision )
465+ end
466+
467+ # Step 3: Regular rule evaluation
449468 variation_result = get_variation ( project_config , rule [ 'id' ] , user , user_profile_tracker , options )
450469 variation_result . reasons = reasons + variation_result . reasons
451470 variation_result
@@ -460,16 +479,26 @@ def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index
460479 # rule - An experiment rule key
461480 # user_context - Optimizely user context instance
462481 #
463- # Returns variation, boolean to skip for eveyone else rule and reasons
482+ # Returns [holdout_decision, variation, skip_to_everyone_else, reasons]
464483 reasons = [ ]
465484 skip_to_everyone_else = false
466485 rule = rules [ rule_index ]
486+
487+ # Step 1: Forced decision check
467488 context = Optimizely ::OptimizelyUserContext ::OptimizelyDecisionContext . new ( flag_key , rule [ 'key' ] )
468489 variation , forced_reasons = validated_forced_decision ( project_config , context , user_context )
469490 reasons . push ( *forced_reasons )
491+ return [ nil , variation , skip_to_everyone_else , reasons ] if variation
470492
471- return [ variation , skip_to_everyone_else , reasons ] if variation
493+ # Step 2: Local holdout check
494+ local_holdouts = project_config . get_holdouts_for_rule ( rule [ 'id' ] )
495+ local_holdouts . each do |holdout |
496+ holdout_decision = get_variation_for_holdout ( holdout , user_context , project_config )
497+ reasons . push ( *holdout_decision . reasons )
498+ return [ holdout_decision . decision , nil , skip_to_everyone_else , reasons ] if holdout_decision . decision
499+ end
472500
501+ # Step 3: Regular rule evaluation
473502 user_id = user_context . user_id
474503 attributes = user_context . user_attributes
475504 bucketing_id , bucketing_id_reasons = get_bucketing_id ( user_id , attributes )
@@ -485,7 +514,7 @@ def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index
485514 message = "User '#{ user_id } ' does not meet the conditions for targeting rule '#{ logging_key } '."
486515 @logger . log ( Logger ::DEBUG , message )
487516 reasons . push ( message )
488- return [ nil , skip_to_everyone_else , reasons ]
517+ return [ nil , nil , skip_to_everyone_else , reasons ]
489518 end
490519
491520 message = "User '#{ user_id } ' meets the audience conditions for targeting rule '#{ logging_key } '."
@@ -505,7 +534,7 @@ def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index
505534 reasons . push ( message )
506535 skip_to_everyone_else = true
507536 end
508- [ bucket_variation , skip_to_everyone_else , reasons ]
537+ [ nil , bucket_variation , skip_to_everyone_else , reasons ]
509538 end
510539
511540 def set_forced_variation ( project_config , experiment_key , user_id , variation_key )
0 commit comments