@@ -325,9 +325,10 @@ public List<DecisionResponse<FeatureDecision>> getVariationsForFeatureList(@Non
325325 DecisionReasons reasons = DefaultDecisionReasons .newInstance ();
326326 reasons .merge (upsReasons );
327327
328- List <Holdout > holdouts = projectConfig .getHoldoutForFlag (featureFlag .getId ());
329- if (!holdouts .isEmpty ()) {
330- for (Holdout holdout : holdouts ) {
328+ // Evaluate global holdouts at flag level (before any rules are iterated)
329+ List <Holdout > globalHoldouts = projectConfig .getGlobalHoldouts ();
330+ if (!globalHoldouts .isEmpty ()) {
331+ for (Holdout holdout : globalHoldouts ) {
331332 DecisionResponse <Variation > holdoutDecision = getVariationForHoldout (holdout , user , projectConfig );
332333 reasons .merge (holdoutDecision .getReasons ());
333334 if (holdoutDecision .getResult () != null ) {
@@ -395,12 +396,44 @@ DecisionResponse<FeatureDecision> getVariationFromExperiment(@Nonnull ProjectCon
395396 @ Nullable UserProfileTracker userProfileTracker ,
396397 @ Nonnull DecisionPath decisionPath ) {
397398 DecisionReasons reasons = DefaultDecisionReasons .newInstance ();
399+ // Cache flagKey once to avoid multiple getKey() calls (important for mock-based tests)
400+ String flagKey = featureFlag .getKey ();
398401 if (!featureFlag .getExperimentIds ().isEmpty ()) {
399402 for (String experimentId : featureFlag .getExperimentIds ()) {
400403 Experiment experiment = projectConfig .getExperimentIdMapping ().get (experimentId );
401404
405+ // Step 1: Check forced decision for this experiment rule first (highest priority).
406+ // We must do this before the local holdout check so forced decisions win.
407+ if (experiment != null ) {
408+ String ruleKey = experiment .getKey ();
409+ OptimizelyDecisionContext fdContext = new OptimizelyDecisionContext (flagKey , ruleKey );
410+ DecisionResponse <Variation > fdResponse = validatedForcedDecision (fdContext , projectConfig , user );
411+ reasons .merge (fdResponse .getReasons ());
412+ if (fdResponse .getResult () != null ) {
413+ return new DecisionResponse <>(
414+ new FeatureDecision (experiment , fdResponse .getResult (), FeatureDecision .DecisionSource .FEATURE_TEST ),
415+ reasons );
416+ }
417+
418+ // Step 2: Check local holdouts targeting this experiment rule.
419+ // Local holdouts run after forced decisions but before regular rule evaluation.
420+ List <Holdout > localHoldouts = projectConfig .getHoldoutsForRule (experiment .getId ());
421+ for (Holdout holdout : localHoldouts ) {
422+ DecisionResponse <Variation > holdoutDecision = getVariationForHoldout (holdout , user , projectConfig );
423+ reasons .merge (holdoutDecision .getReasons ());
424+ if (holdoutDecision .getResult () != null ) {
425+ return new DecisionResponse <>(
426+ new FeatureDecision (holdout , holdoutDecision .getResult (), FeatureDecision .DecisionSource .HOLDOUT ),
427+ reasons );
428+ }
429+ }
430+ }
431+
432+ // Step 3: Regular rule evaluation (getVariationFromExperimentRule also checks
433+ // forced decisions internally but it will find no forced decision since we already
434+ // checked above; the duplicate check is harmless).
402435 DecisionResponse <Variation > decisionVariation =
403- getVariationFromExperimentRule (projectConfig , featureFlag . getKey () , experiment , user , options , userProfileTracker , decisionPath );
436+ getVariationFromExperimentRule (projectConfig , flagKey , experiment , user , options , userProfileTracker , decisionPath );
404437 reasons .merge (decisionVariation .getReasons ());
405438 Variation variation = decisionVariation .getResult ();
406439 String cmabUuid = decisionVariation .getCmabUuid ();
@@ -421,7 +454,7 @@ DecisionResponse<FeatureDecision> getVariationFromExperiment(@Nonnull ProjectCon
421454 }
422455 }
423456 } else {
424- String message = reasons .addInfo ("The feature flag \" %s\" is not used in any experiments." , featureFlag . getKey () );
457+ String message = reasons .addInfo ("The feature flag \" %s\" is not used in any experiments." , flagKey );
425458 logger .info (message );
426459 }
427460
@@ -468,7 +501,33 @@ DecisionResponse<FeatureDecision> getVariationForFeatureInRollout(@Nonnull Featu
468501
469502 int index = 0 ;
470503 while (index < rolloutRulesLength ) {
504+ Experiment rolloutRule = rollout .getExperiments ().get (index );
505+
506+ // Step 1: Check forced decision for this delivery rule (highest priority).
507+ String rolloutRuleKey = rolloutRule .getKey ();
508+ OptimizelyDecisionContext rolloutFdContext = new OptimizelyDecisionContext (featureFlag .getKey (), rolloutRuleKey );
509+ DecisionResponse <Variation > rolloutFdResponse = validatedForcedDecision (rolloutFdContext , projectConfig , user );
510+ reasons .merge (rolloutFdResponse .getReasons ());
511+ if (rolloutFdResponse .getResult () != null ) {
512+ FeatureDecision featureDecision = new FeatureDecision (rolloutRule , rolloutFdResponse .getResult (), FeatureDecision .DecisionSource .ROLLOUT );
513+ return new DecisionResponse <>(featureDecision , reasons );
514+ }
471515
516+ // Step 2: Check local holdouts targeting this delivery rule.
517+ // Local holdouts run after forced decisions but before regular delivery rule evaluation.
518+ List <Holdout > rolloutLocalHoldouts = projectConfig .getHoldoutsForRule (rolloutRule .getId ());
519+ for (Holdout holdout : rolloutLocalHoldouts ) {
520+ DecisionResponse <Variation > holdoutDecision = getVariationForHoldout (holdout , user , projectConfig );
521+ reasons .merge (holdoutDecision .getReasons ());
522+ if (holdoutDecision .getResult () != null ) {
523+ return new DecisionResponse <>(
524+ new FeatureDecision (holdout , holdoutDecision .getResult (), FeatureDecision .DecisionSource .HOLDOUT ),
525+ reasons );
526+ }
527+ }
528+
529+ // Step 3: Regular delivery rule evaluation (getVariationFromDeliveryRule also checks
530+ // forced decisions internally; the duplicate check is harmless).
472531 DecisionResponse <AbstractMap .SimpleEntry > decisionVariationResponse = getVariationFromDeliveryRule (
473532 projectConfig ,
474533 featureFlag .getKey (),
@@ -836,7 +895,7 @@ private DecisionResponse<Variation> getVariationFromExperimentRule(@Nonnull Proj
836895 DecisionReasons reasons = DefaultDecisionReasons .newInstance ();
837896
838897 String ruleKey = rule != null ? rule .getKey () : null ;
839- // Check Forced-Decision
898+ // Step 1: Check Forced-Decision
840899 OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext (flagKey , ruleKey );
841900 DecisionResponse <Variation > forcedDecisionResponse = validatedForcedDecision (optimizelyDecisionContext , projectConfig , user );
842901
@@ -846,7 +905,9 @@ private DecisionResponse<Variation> getVariationFromExperimentRule(@Nonnull Proj
846905 if (variation != null ) {
847906 return new DecisionResponse (variation , reasons );
848907 }
849- //regular decision
908+
909+ // Regular rule decision (local holdouts for experiment rules are checked by the caller
910+ // getVariationFromExperiment, where the FeatureDecision source can be set to HOLDOUT)
850911 DecisionResponse <Variation > decisionResponse = getVariation (rule , user , projectConfig , options , userProfileTracker , null , decisionPath );
851912 reasons .merge (decisionResponse .getReasons ());
852913
0 commit comments