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. ///