diff --git a/OptimizelySDK.Tests/LocalHoldoutsSectionTest.cs b/OptimizelySDK.Tests/LocalHoldoutsSectionTest.cs
new file mode 100644
index 00000000..0a96f05f
--- /dev/null
+++ b/OptimizelySDK.Tests/LocalHoldoutsSectionTest.cs
@@ -0,0 +1,353 @@
+/*
+ * Copyright 2026, Optimizely
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System.IO;
+using System.Linq;
+using Moq;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+using OptimizelySDK.Config;
+using OptimizelySDK.Entity;
+using OptimizelySDK.ErrorHandler;
+using OptimizelySDK.Logger;
+
+namespace OptimizelySDK.Tests
+{
+ ///
+ /// Tests for the new top-level 'localHoldouts' datafile section (FSSDK-12760).
+ ///
+ /// Local holdouts now live in a dedicated 'localHoldouts' section, separate from
+ /// 'holdouts' which carries only global holdouts. Older SDK versions (Gen 1/Gen 2)
+ /// will ignore this unknown top-level key entirely — that's the whole point of the
+ /// backward-compatible design — but Gen 3 SDKs must parse it as the source of truth
+ /// for local (rule-scoped) holdouts.
+ ///
+ /// Contract:
+ /// - 'holdouts' → ALL entries are global. Any IncludedRules is stripped/ignored.
+ /// - 'localHoldouts' → ALL entries are local. IncludedRules is REQUIRED;
+ /// entries missing it are logged and excluded.
+ ///
+ [TestFixture]
+ public class LocalHoldoutsSectionTest
+ {
+ private Mock LoggerMock;
+ private JObject TestData;
+
+ [SetUp]
+ public void Initialize()
+ {
+ LoggerMock = new Mock();
+
+ var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
+ "TestData", "HoldoutTestData.json");
+ var jsonContent = File.ReadAllText(testDataPath);
+ TestData = JObject.Parse(jsonContent);
+ }
+
+ private DatafileProjectConfig BuildConfig(string fixtureName)
+ {
+ var datafile = TestData[fixtureName].ToString();
+ return DatafileProjectConfig.Create(datafile, LoggerMock.Object,
+ new NoOpErrorHandler()) as DatafileProjectConfig;
+ }
+
+ // -----------------------------------------------------------------------
+ // localHoldouts section parsing — happy path
+ // -----------------------------------------------------------------------
+
+ [Test]
+ public void TestLocalHoldoutsSection_ExposedAsTopLevelProperty()
+ {
+ var config = BuildConfig("datafileWithLocalHoldoutsSection");
+
+ Assert.IsNotNull(config.LocalHoldouts,
+ "LocalHoldouts property should be exposed on DatafileProjectConfig");
+ Assert.AreEqual(2, config.LocalHoldouts.Length,
+ "LocalHoldouts should contain both entries from the datafile (valid + invalid)");
+ }
+
+ [Test]
+ public void TestLocalHoldoutsSection_RegistersInRuleMap()
+ {
+ // Entries in 'localHoldouts' must be registered under each rule in IncludedRules.
+ var config = BuildConfig("datafileWithLocalHoldoutsSection");
+
+ var ruleAHoldouts = config.GetHoldoutsForRule("rule_a");
+ Assert.AreEqual(1, ruleAHoldouts.Count,
+ "rule_a should be targeted by exactly one local holdout");
+ Assert.AreEqual("section_local_1", ruleAHoldouts[0].Id,
+ "rule_a should be targeted by section_local_1");
+ }
+
+ [Test]
+ public void TestLocalHoldoutsSection_EntriesExcludedFromGlobalList()
+ {
+ // Entries in 'localHoldouts' must NOT appear in GetGlobalHoldouts().
+ var config = BuildConfig("datafileWithLocalHoldoutsSection");
+
+ var globals = config.GetGlobalHoldouts();
+ Assert.IsFalse(globals.Any(h => h.Id == "section_local_1"),
+ "Local holdout must not appear in the global holdouts list");
+ }
+
+ [Test]
+ public void TestLocalHoldoutsSection_LocalEntryIsRetrievableById()
+ {
+ // Entries from both sections must be tracked in the unified holdout id map.
+ var config = BuildConfig("datafileWithLocalHoldoutsSection");
+
+ Assert.IsNotNull(config.GetHoldout("section_global_1"),
+ "Global-section holdout should be retrievable by ID");
+ Assert.IsNotNull(config.GetHoldout("section_local_1"),
+ "Local-section holdout should be retrievable by ID");
+ }
+
+ // -----------------------------------------------------------------------
+ // localHoldouts section — variation maps must include local holdouts
+ // -----------------------------------------------------------------------
+
+ [Test]
+ public void TestLocalHoldoutsSection_VariationsRegisteredInVariationMaps()
+ {
+ // The decision service resolves variations by holdout key, so local holdouts'
+ // variations must be present in the variation maps.
+ var config = BuildConfig("datafileWithLocalHoldoutsSection");
+
+ Assert.IsTrue(config.VariationKeyMap.ContainsKey("section_local_holdout_1"),
+ "Local holdout key should be present in VariationKeyMap");
+ Assert.IsTrue(config.VariationKeyMap["section_local_holdout_1"]
+ .ContainsKey("section_local_off"),
+ "Local holdout's variation key should be registered");
+ }
+
+ // -----------------------------------------------------------------------
+ // Section partition — IncludedRules on global-section entries is ignored
+ // -----------------------------------------------------------------------
+
+ [Test]
+ public void TestGlobalSection_IncludedRulesOnEntryIsStrippedAtParseTime()
+ {
+ // Any IncludedRules field on a 'holdouts' entry must NOT narrow its scope.
+ // Section membership in 'holdouts' alone determines global scope.
+ var config = BuildConfig("datafileWithLocalHoldoutsSection");
+
+ var strayHoldout = config.GetHoldout("section_global_with_stray_rules");
+ Assert.IsNotNull(strayHoldout,
+ "Global-section holdout with stray includedRules should still be loaded");
+
+ Assert.IsNull(strayHoldout.IncludedRules,
+ "IncludedRules on a global-section entry must be stripped at parse time");
+ Assert.IsTrue(strayHoldout.IsGlobal,
+ "Global-section entry must always classify as global, regardless of source IncludedRules");
+
+ // Must NOT appear under the rule it spuriously listed
+ var holdoutsForStrayRule = config.GetHoldoutsForRule("should_be_ignored_rule");
+ Assert.AreEqual(0, holdoutsForStrayRule.Count,
+ "Stray includedRules on a global-section entry must not place it in the rule map");
+
+ // Must appear in the global list
+ var globals = config.GetGlobalHoldouts();
+ Assert.IsTrue(globals.Any(h => h.Id == "section_global_with_stray_rules"),
+ "Global-section entry with stray includedRules must still be in the global list");
+ }
+
+ // -----------------------------------------------------------------------
+ // Invalid local holdouts — missing IncludedRules is logged and excluded
+ // -----------------------------------------------------------------------
+
+ [Test]
+ public void TestLocalHoldoutsSection_MissingIncludedRulesIsExcluded()
+ {
+ // Entries in 'localHoldouts' without IncludedRules are invalid per spec.
+ // SDK must exclude them from evaluation. It must NOT fall back to global application.
+ var config = BuildConfig("datafileWithLocalHoldoutsSection");
+
+ // Invalid entry must not be retrievable by ID (excluded from holdout map).
+ Assert.IsNull(config.GetHoldout("section_local_invalid"),
+ "Invalid local holdout (missing IncludedRules) must be excluded from holdout map");
+
+ // Invalid entry must not be applied as global.
+ var globals = config.GetGlobalHoldouts();
+ Assert.IsFalse(globals.Any(h => h.Id == "section_local_invalid"),
+ "Invalid local holdout must NOT fall back to global application");
+ }
+
+ [Test]
+ public void TestLocalHoldoutsSection_MissingIncludedRulesLogsError()
+ {
+ // Verify an error log is emitted for an invalid local holdout entry.
+ BuildConfig("datafileWithLocalHoldoutsSection");
+
+ LoggerMock.Verify(
+ l => l.Log(LogLevel.ERROR,
+ It.Is(msg =>
+ msg.Contains("section_local_invalid_key") &&
+ msg.Contains("includedRules"))),
+ Times.AtLeastOnce(),
+ "Expected an error log mentioning the invalid local holdout's key and includedRules");
+ }
+
+ // -----------------------------------------------------------------------
+ // Backward compatibility — datafiles without 'localHoldouts' section
+ // -----------------------------------------------------------------------
+
+ [Test]
+ public void TestBackwardCompat_DatafileWithoutLocalHoldoutsSection()
+ {
+ // Old datafiles that only emit the 'holdouts' section must continue to work.
+ // Every entry is global; LocalHoldouts defaults to empty array.
+ var config = BuildConfig("datafileWithHoldouts");
+
+ Assert.IsNotNull(config.LocalHoldouts,
+ "LocalHoldouts must default to an empty array when the section is absent");
+ Assert.AreEqual(0, config.LocalHoldouts.Length,
+ "Missing 'localHoldouts' key should result in an empty LocalHoldouts array");
+
+ // The 'holdouts' entries are still loaded as global, and no errors are produced.
+ Assert.IsTrue(config.GetGlobalHoldouts().Count > 0,
+ "Global holdouts from the 'holdouts' section should still be loaded");
+ }
+
+ // -----------------------------------------------------------------------
+ // Direct DatafileProjectConfig.Create with minimal inline datafiles
+ // -----------------------------------------------------------------------
+
+ [Test]
+ public void TestBackwardCompat_DatafileMissingBothHoldoutSections()
+ {
+ // Datafile that emits neither 'holdouts' nor 'localHoldouts' must produce empty lists.
+ var datafile = @"{
+ ""version"": ""4"",
+ ""projectId"": ""p1"",
+ ""accountId"": ""a1"",
+ ""revision"": ""1"",
+ ""experiments"": [],
+ ""groups"": [],
+ ""attributes"": [],
+ ""audiences"": [],
+ ""events"": [],
+ ""featureFlags"": [],
+ ""rollouts"": [],
+ ""anonymizeIP"": false
+ }";
+
+ var config = DatafileProjectConfig.Create(datafile, LoggerMock.Object,
+ new NoOpErrorHandler()) as DatafileProjectConfig;
+
+ Assert.IsNotNull(config);
+ Assert.IsNotNull(config.Holdouts);
+ Assert.AreEqual(0, config.Holdouts.Length);
+ Assert.IsNotNull(config.LocalHoldouts);
+ Assert.AreEqual(0, config.LocalHoldouts.Length);
+ Assert.AreEqual(0, config.GetGlobalHoldouts().Count);
+ Assert.AreEqual(0, config.GetHoldoutsForRule("any_rule").Count);
+ }
+
+ [Test]
+ public void TestBothSectionsPartitionCorrectly()
+ {
+ // When both 'holdouts' and 'localHoldouts' are present, scope is enforced by
+ // section membership — entries never cross over.
+ var datafile = @"{
+ ""version"": ""4"",
+ ""projectId"": ""p1"",
+ ""accountId"": ""a1"",
+ ""revision"": ""1"",
+ ""experiments"": [],
+ ""groups"": [],
+ ""attributes"": [],
+ ""audiences"": [],
+ ""events"": [],
+ ""featureFlags"": [],
+ ""rollouts"": [],
+ ""anonymizeIP"": false,
+ ""holdouts"": [
+ {""id"": ""g1"", ""key"": ""g1k"", ""status"": ""Running"",
+ ""variations"": [], ""trafficAllocation"": [],
+ ""audienceIds"": [], ""audienceConditions"": []},
+ {""id"": ""g2"", ""key"": ""g2k"", ""status"": ""Running"",
+ ""variations"": [], ""trafficAllocation"": [],
+ ""audienceIds"": [], ""audienceConditions"": []}
+ ],
+ ""localHoldouts"": [
+ {""id"": ""l1"", ""key"": ""l1k"", ""status"": ""Running"",
+ ""variations"": [], ""trafficAllocation"": [],
+ ""audienceIds"": [], ""audienceConditions"": [],
+ ""includedRules"": [""rule_a""]},
+ {""id"": ""l2"", ""key"": ""l2k"", ""status"": ""Running"",
+ ""variations"": [], ""trafficAllocation"": [],
+ ""audienceIds"": [], ""audienceConditions"": [],
+ ""includedRules"": [""rule_b""]}
+ ]
+ }";
+
+ var config = DatafileProjectConfig.Create(datafile, LoggerMock.Object,
+ new NoOpErrorHandler()) as DatafileProjectConfig;
+
+ var globalIds = config.GetGlobalHoldouts().Select(h => h.Id).OrderBy(s => s).ToArray();
+ CollectionAssert.AreEqual(new[] { "g1", "g2" }, globalIds,
+ "Global section entries must be exactly the 'holdouts' entries");
+
+ Assert.AreEqual(1, config.GetHoldoutsForRule("rule_a").Count);
+ Assert.AreEqual("l1", config.GetHoldoutsForRule("rule_a")[0].Id);
+ Assert.AreEqual(1, config.GetHoldoutsForRule("rule_b").Count);
+ Assert.AreEqual("l2", config.GetHoldoutsForRule("rule_b")[0].Id);
+ }
+
+ [Test]
+ public void TestLocalSection_EmptyIncludedRulesIsValid_TargetsNoRules()
+ {
+ // IncludedRules == [] is valid (entity is stored), but targets no rules.
+ // Not invalid, not global — matches existing entity-level semantics where [] != null.
+ var datafile = @"{
+ ""version"": ""4"",
+ ""projectId"": ""p1"",
+ ""accountId"": ""a1"",
+ ""revision"": ""1"",
+ ""experiments"": [],
+ ""groups"": [],
+ ""attributes"": [],
+ ""audiences"": [],
+ ""events"": [],
+ ""featureFlags"": [],
+ ""rollouts"": [],
+ ""anonymizeIP"": false,
+ ""localHoldouts"": [
+ {""id"": ""l_empty"", ""key"": ""l_empty_k"", ""status"": ""Running"",
+ ""variations"": [], ""trafficAllocation"": [],
+ ""audienceIds"": [], ""audienceConditions"": [],
+ ""includedRules"": []}
+ ]
+ }";
+
+ var config = DatafileProjectConfig.Create(datafile, LoggerMock.Object,
+ new NoOpErrorHandler()) as DatafileProjectConfig;
+
+ // Stored (valid) — retrievable by id
+ var stored = config.GetHoldout("l_empty");
+ Assert.IsNotNull(stored, "Local holdout with empty IncludedRules must still be stored");
+ Assert.IsFalse(stored.IsGlobal, "Empty IncludedRules is local, not global");
+
+ // Not in any rule map
+ Assert.AreEqual(0, config.GetHoldoutsForRule("any_rule").Count,
+ "Empty IncludedRules must match no rules");
+
+ // Not global
+ Assert.AreEqual(0, config.GetGlobalHoldouts().Count,
+ "Local holdout with empty IncludedRules must not be promoted to global");
+ }
+ }
+}
diff --git a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
index 8aed9432..98db5acf 100644
--- a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
+++ b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
@@ -115,6 +115,7 @@
+
diff --git a/OptimizelySDK.Tests/TestData/HoldoutTestData.json b/OptimizelySDK.Tests/TestData/HoldoutTestData.json
index eb65ccc6..01e8a5f7 100644
--- a/OptimizelySDK.Tests/TestData/HoldoutTestData.json
+++ b/OptimizelySDK.Tests/TestData/HoldoutTestData.json
@@ -211,6 +211,123 @@
}
]
},
+ "datafileWithLocalHoldoutsSection": {
+ "version": "4",
+ "rollouts": [],
+ "projectId": "test_project",
+ "experiments": [],
+ "groups": [],
+ "attributes": [],
+ "audiences": [],
+ "layers": [],
+ "events": [],
+ "revision": "1",
+ "accountId": "12345",
+ "anonymizeIP": false,
+ "featureFlags": [
+ {
+ "id": "flag_1",
+ "key": "test_flag_1",
+ "experimentIds": [],
+ "rolloutId": "",
+ "variables": []
+ }
+ ],
+ "holdouts": [
+ {
+ "id": "section_global_1",
+ "key": "section_global_holdout_1",
+ "status": "Running",
+ "layerId": "layer_sg1",
+ "variations": [
+ {
+ "id": "sgvar_1",
+ "key": "section_global_off",
+ "featureEnabled": false,
+ "variables": []
+ }
+ ],
+ "trafficAllocation": [
+ {
+ "entityId": "sgvar_1",
+ "endOfRange": 10000
+ }
+ ],
+ "audienceIds": [],
+ "audienceConditions": []
+ },
+ {
+ "id": "section_global_with_stray_rules",
+ "key": "section_global_with_stray_rules_key",
+ "status": "Running",
+ "layerId": "layer_sg_stray",
+ "variations": [
+ {
+ "id": "sgstray_var_1",
+ "key": "section_global_stray_off",
+ "featureEnabled": false,
+ "variables": []
+ }
+ ],
+ "trafficAllocation": [
+ {
+ "entityId": "sgstray_var_1",
+ "endOfRange": 10000
+ }
+ ],
+ "audienceIds": [],
+ "audienceConditions": [],
+ "includedRules": ["should_be_ignored_rule"]
+ }
+ ],
+ "localHoldouts": [
+ {
+ "id": "section_local_1",
+ "key": "section_local_holdout_1",
+ "status": "Running",
+ "layerId": "layer_sl1",
+ "variations": [
+ {
+ "id": "slvar_1",
+ "key": "section_local_off",
+ "featureEnabled": false,
+ "variables": []
+ }
+ ],
+ "trafficAllocation": [
+ {
+ "entityId": "slvar_1",
+ "endOfRange": 10000
+ }
+ ],
+ "audienceIds": [],
+ "audienceConditions": [],
+ "includedRules": ["rule_a"]
+ },
+ {
+ "id": "section_local_invalid",
+ "key": "section_local_invalid_key",
+ "status": "Running",
+ "layerId": "layer_sl_invalid",
+ "variations": [
+ {
+ "id": "slinvalid_var_1",
+ "key": "section_local_invalid_off",
+ "featureEnabled": false,
+ "variables": []
+ }
+ ],
+ "trafficAllocation": [
+ {
+ "entityId": "slinvalid_var_1",
+ "endOfRange": 10000
+ }
+ ],
+ "audienceIds": [],
+ "audienceConditions": []
+ }
+ ]
+ },
"datafileWithLocalHoldouts": {
"version": "4",
"rollouts": [
diff --git a/OptimizelySDK/Config/DatafileProjectConfig.cs b/OptimizelySDK/Config/DatafileProjectConfig.cs
index 4bf9a00c..901cf7e6 100644
--- a/OptimizelySDK/Config/DatafileProjectConfig.cs
+++ b/OptimizelySDK/Config/DatafileProjectConfig.cs
@@ -299,10 +299,23 @@ private Dictionary> _VariationIdMap
public Rollout[] Rollouts { get; set; }
///
- /// Associative list of Holdouts.
+ /// Associative list of Holdouts (from the top-level 'holdouts' datafile section).
+ /// Per FSSDK-12760, ALL entries here are global holdouts. Any 'includedRules' field
+ /// on entries in this section is stripped at parse time so the entity is unambiguously
+ /// global. Section membership is the sole signal for scope.
///
public Holdout[] Holdouts { get; set; }
+ ///
+ /// Associative list of Local Holdouts (from the top-level 'localHoldouts' datafile section).
+ /// Per FSSDK-12760, ALL entries here are local (rule-scoped via IncludedRules) and the
+ /// IncludedRules field is REQUIRED. Entries missing IncludedRules are logged and excluded
+ /// from evaluation. Older SDK versions ignore this unknown top-level key entirely, which
+ /// is the basis of the backward-compatible design.
+ ///
+ [JsonProperty("localHoldouts")]
+ public Holdout[] LocalHoldouts { get; set; }
+
///
/// Associative list of Integrations.
///
@@ -327,6 +340,7 @@ private void Initialize()
FeatureFlags = FeatureFlags ?? new FeatureFlag[0];
Rollouts = Rollouts ?? new Rollout[0];
Holdouts = Holdouts ?? new Holdout[0];
+ LocalHoldouts = LocalHoldouts ?? new Holdout[0];
Integrations = Integrations ?? new Integration[0];
_ExperimentKeyMap = new Dictionary();
@@ -423,24 +437,25 @@ private void Initialize()
}
// Adding Holdout variations in variation id and key maps.
- if (Holdouts != null)
+ // Per FSSDK-12760, both sections ('holdouts' = global, 'localHoldouts' = local) need to
+ // have their variation maps populated so the decision service can resolve their variations.
+ var allHoldoutsForVariationMaps = (Holdouts ?? new Holdout[0])
+ .Concat(LocalHoldouts ?? new Holdout[0]);
+ foreach (var holdout in allHoldoutsForVariationMaps)
{
- foreach (var holdout in Holdouts)
- {
- _VariationKeyMap[holdout.Key] = new Dictionary();
- _VariationIdMap[holdout.Key] = new Dictionary();
- _VariationIdMapByExperimentId[holdout.Id] = new Dictionary();
- _VariationKeyMapByExperimentId[holdout.Id] = new Dictionary();
+ _VariationKeyMap[holdout.Key] = new Dictionary();
+ _VariationIdMap[holdout.Key] = new Dictionary();
+ _VariationIdMapByExperimentId[holdout.Id] = new Dictionary();
+ _VariationKeyMapByExperimentId[holdout.Id] = new Dictionary();
- if (holdout.Variations != null)
+ if (holdout.Variations != null)
+ {
+ foreach (var variation in holdout.Variations)
{
- foreach (var variation in holdout.Variations)
- {
- _VariationKeyMap[holdout.Key][variation.Key] = variation;
- _VariationIdMap[holdout.Key][variation.Id] = variation;
- _VariationKeyMapByExperimentId[holdout.Id][variation.Key] = variation;
- _VariationIdMapByExperimentId[holdout.Id][variation.Id] = variation;
- }
+ _VariationKeyMap[holdout.Key][variation.Key] = variation;
+ _VariationIdMap[holdout.Key][variation.Id] = variation;
+ _VariationKeyMapByExperimentId[holdout.Id][variation.Key] = variation;
+ _VariationIdMapByExperimentId[holdout.Id][variation.Id] = variation;
}
}
}
@@ -561,8 +576,67 @@ private void Initialize()
_FlagVariationMap = flagToVariationsMap;
- // Initialize HoldoutConfig for managing flag-to-holdout relationships
- _holdoutConfig = new HoldoutConfig(Holdouts ?? new Holdout[0]);
+ // Initialize HoldoutConfig for managing flag-to-holdout relationships.
+ //
+ // Per FSSDK-12760, two top-level datafile sections drive holdout scoping (Gen 3+):
+ // - 'holdouts' → ALL entries are global holdouts (applied to every flag).
+ // Any 'includedRules' field on these entries is IGNORED;
+ // section membership alone determines scope.
+ // - 'localHoldouts' → ALL entries are local holdouts (rule-scoped via
+ // 'includedRules'). Entries missing 'includedRules' are
+ // invalid and skipped with an error log.
+ //
+ // Backward compatibility: older datafiles that only emit the 'holdouts' section
+ // continue to work — every entry is treated as global. The 'localHoldouts' key is
+ // simply absent and parsed as an empty list.
+ _holdoutConfig = new HoldoutConfig(BuildCombinedHoldouts());
+ }
+
+ ///
+ /// Combine the 'holdouts' (global) and 'localHoldouts' (local) sections into a single
+ /// array for HoldoutConfig. Enforces section-based scoping at parse time:
+ /// - Strips 'IncludedRules' from entries in the global 'holdouts' section (so they
+ /// always classify as global even if the datafile incorrectly includes that field).
+ /// - Validates entries in 'localHoldouts': missing or null IncludedRules is invalid;
+ /// such entries are logged and excluded (no fallback to global application).
+ ///
+ private Holdout[] BuildCombinedHoldouts()
+ {
+ var combined = new List();
+
+ // Global section: section membership is the sole signal for scope.
+ // Drop any IncludedRules on global-section entries so the entity is unambiguously global.
+ foreach (var holdout in Holdouts ?? new Holdout[0])
+ {
+ if (holdout == null)
+ {
+ continue;
+ }
+
+ holdout.IncludedRules = null;
+ combined.Add(holdout);
+ }
+
+ // Local section: IncludedRules is REQUIRED. Invalid entries are logged and skipped.
+ foreach (var holdout in LocalHoldouts ?? new Holdout[0])
+ {
+ if (holdout == null)
+ {
+ continue;
+ }
+
+ if (holdout.IncludedRules == null)
+ {
+ var identifier = !string.IsNullOrEmpty(holdout.Key) ? holdout.Key : holdout.Id;
+ Logger?.Log(LogLevel.ERROR,
+ $"Local holdout \"{identifier}\" is missing required \"includedRules\" field and will be excluded from evaluation.");
+ continue;
+ }
+
+ combined.Add(holdout);
+ }
+
+ return combined.ToArray();
}
///
@@ -910,8 +984,10 @@ public Holdout GetHoldout(string holdoutId)
}
///
- /// Returns all global holdouts (holdouts where IncludedRules is null).
+ /// Returns all global holdouts (parsed from the top-level 'holdouts' datafile section).
/// Global holdouts apply to all rules across all flags and are evaluated at flag level.
+ /// Section membership is the sole signal for global scope — any 'includedRules' field
+ /// on entries in the 'holdouts' section is stripped at parse time and ignored.
///
/// Read-only list of global holdouts
public List GetGlobalHoldouts()
@@ -921,7 +997,9 @@ public List GetGlobalHoldouts()
///
/// Returns local holdouts that target a specific rule ID.
- /// Local holdouts are evaluated per-rule, after forced decisions but before regular rule evaluation.
+ /// Local holdouts come from the top-level 'localHoldouts' datafile section and are
+ /// scoped per-rule via their IncludedRules field. They are evaluated per-rule, after
+ /// forced decisions but before regular rule evaluation.
///
/// The rule ID to look up holdouts for
/// Read-only list of local holdouts targeting the given rule, or empty list if none
diff --git a/OptimizelySDK/Entity/Holdout.cs b/OptimizelySDK/Entity/Holdout.cs
index 7ed44835..4e69dcee 100644
--- a/OptimizelySDK/Entity/Holdout.cs
+++ b/OptimizelySDK/Entity/Holdout.cs
@@ -47,16 +47,18 @@ public override string LayerId
}
///
- /// Optional array of rule IDs that this holdout targets (local holdout).
- /// When null, the holdout applies to all rules across all flags (global holdout).
- /// When set to an array (even empty), the holdout only applies to the specified rules.
- /// Rule IDs in this array are experiment/delivery rule IDs from the datafile, NOT flag IDs.
+ /// Per-rule targeting for local holdouts. Scope comes from the datafile
+ /// section, not this field; DatafileProjectConfig strips it on entries
+ /// from the 'holdouts' section so they remain unambiguously global.
+ /// Required (non-null) on entries from the 'localHoldouts' section.
///
public string[] IncludedRules { get; set; }
///
- /// Returns true if this is a global holdout (IncludedRules is null),
- /// false if this is a local holdout (IncludedRules is a non-null array).
+ /// True if this is a global holdout (IncludedRules is null).
+ /// Scope is set by the datafile section ('holdouts' vs 'localHoldouts');
+ /// DatafileProjectConfig strips 'includedRules' on 'holdouts' entries, so
+ /// this property stays consistent with section membership.
///
public bool IsGlobal => IncludedRules == null;
}
diff --git a/OptimizelySDK/ProjectConfig.cs b/OptimizelySDK/ProjectConfig.cs
index e0321190..0ed8cc58 100644
--- a/OptimizelySDK/ProjectConfig.cs
+++ b/OptimizelySDK/ProjectConfig.cs
@@ -182,9 +182,17 @@ public interface ProjectConfig
///
/// Associative list of Holdouts.
+ /// Entries here are ALL global (section membership is the sole signal for scope).
///
Holdout[] Holdouts { get; set; }
+ ///
+ /// Associative list of Local Holdouts (top-level 'localHoldouts' datafile section).
+ /// Entries here are ALL local — rule-scoped via IncludedRules.
+ /// Older SDK versions ignore this unknown top-level key, providing backward compatibility.
+ ///
+ Holdout[] LocalHoldouts { get; set; }
+
///
/// Associative list of Integrations.
///