diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
index 2ba52d48..f73e809c 100644
--- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
+++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
@@ -187,6 +187,12 @@
Cmab\DefaultCmabClient.cs
+
+ Cmab\ICmabService.cs
+
+
+ Cmab\DefaultCmabService.cs
+
Cmab\CmabRetryConfig.cs
diff --git a/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs
new file mode 100644
index 00000000..9dac9699
--- /dev/null
+++ b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs
@@ -0,0 +1,388 @@
+/*
+* Copyright 2025, 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
+*
+* http://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;
+using System.Collections.Generic;
+using Moq;
+using NUnit.Framework;
+using OptimizelySDK.Cmab;
+using OptimizelySDK.Config;
+using OptimizelySDK.Entity;
+using OptimizelySDK.ErrorHandler;
+using OptimizelySDK.Logger;
+using OptimizelySDK.Odp;
+using OptimizelySDK.OptimizelyDecisions;
+using AttributeEntity = OptimizelySDK.Entity.Attribute;
+
+namespace OptimizelySDK.Tests.CmabTests
+{
+ [TestFixture]
+ public class DefaultCmabServiceTest
+ {
+ private Mock _mockCmabClient;
+ private LruCache _cmabCache;
+ private DefaultCmabService _cmabService;
+ private ILogger _logger;
+ private ProjectConfig _config;
+ private Optimizely _optimizely;
+
+ private const string TEST_RULE_ID = "exp1";
+ private const string TEST_USER_ID = "user123";
+ private const string AGE_ATTRIBUTE_ID = "66";
+ private const string LOCATION_ATTRIBUTE_ID = "77";
+
+ [SetUp]
+ public void SetUp()
+ {
+ _mockCmabClient = new Mock(MockBehavior.Strict);
+ _logger = new NoOpLogger();
+ _cmabCache = new LruCache(maxSize: 10, itemTimeout: TimeSpan.FromMinutes(5), logger: _logger);
+ _cmabService = new DefaultCmabService(_cmabCache, _mockCmabClient.Object, _logger);
+
+ _config = DatafileProjectConfig.Create(TestData.Datafile, _logger, new NoOpErrorHandler());
+ _optimizely = new Optimizely(TestData.Datafile, null, _logger, new NoOpErrorHandler());
+ }
+
+ [Test]
+ public void ReturnsDecisionFromCacheWhenHashMatches()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } }
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } });
+ var filteredAttributes = new UserAttributes(new Dictionary { { "age", 25 } });
+ var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID);
+
+ _cmabCache.Save(cacheKey, new CmabCacheEntry
+ {
+ AttributesHash = DefaultCmabService.HashAttributes(filteredAttributes),
+ CmabUuid = "uuid-cached",
+ VariationId = "varA"
+ });
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null);
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varA", decision.VariationId);
+ Assert.AreEqual("uuid-cached", decision.CmabUuid);
+ _mockCmabClient.Verify(c => c.FetchDecision(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public void IgnoresCacheWhenOptionSpecified()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } }
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } });
+ var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID);
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.Is>(attrs => attrs != null && attrs.Count == 1 && attrs.ContainsKey("age") && (int)attrs["age"] == 25),
+ It.IsAny(),
+ It.IsAny())).Returns("varB");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID,
+ new[] { OptimizelyDecideOption.IGNORE_CMAB_CACHE });
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varB", decision.VariationId);
+ Assert.IsNull(_cmabCache.Lookup(cacheKey));
+ _mockCmabClient.VerifyAll();
+ }
+
+ [Test]
+ public void ResetsCacheWhenOptionSpecified()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } }
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } });
+ var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID);
+
+ _cmabCache.Save(cacheKey, new CmabCacheEntry
+ {
+ AttributesHash = "stale",
+ CmabUuid = "uuid-old",
+ VariationId = "varOld"
+ });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.Is>(attrs => attrs.Count == 1 && (int)attrs["age"] == 25),
+ It.IsAny(),
+ It.IsAny())).Returns("varNew");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID,
+ new[] { OptimizelyDecideOption.RESET_CMAB_CACHE });
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varNew", decision.VariationId);
+ var cachedEntry = _cmabCache.Lookup(cacheKey);
+ Assert.IsNotNull(cachedEntry);
+ Assert.AreEqual("varNew", cachedEntry.VariationId);
+ Assert.AreEqual(decision.CmabUuid, cachedEntry.CmabUuid);
+ _mockCmabClient.VerifyAll();
+ }
+
+ [Test]
+ public void InvalidatesUserEntryWhenOptionSpecified()
+ {
+ var otherUserId = "other";
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } }
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } });
+
+ var targetKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID);
+ var otherKey = DefaultCmabService.GetCacheKey(otherUserId, TEST_RULE_ID);
+
+ _cmabCache.Save(targetKey, new CmabCacheEntry
+ {
+ AttributesHash = "old_hash",
+ CmabUuid = "uuid-old",
+ VariationId = "varOld"
+ });
+ _cmabCache.Save(otherKey, new CmabCacheEntry
+ {
+ AttributesHash = "other_hash",
+ CmabUuid = "uuid-other",
+ VariationId = "varOther"
+ });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.Is>(attrs => attrs.Count == 1 && (int)attrs["age"] == 25),
+ It.IsAny(),
+ It.IsAny())).Returns("varNew");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID,
+ new[] { OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE });
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varNew", decision.VariationId);
+ var updatedEntry = _cmabCache.Lookup(targetKey);
+ Assert.IsNotNull(updatedEntry);
+ Assert.AreEqual(decision.CmabUuid, updatedEntry.CmabUuid);
+ Assert.AreEqual("varNew", updatedEntry.VariationId);
+
+ var otherEntry = _cmabCache.Lookup(otherKey);
+ Assert.IsNotNull(otherEntry);
+ Assert.AreEqual("varOther", otherEntry.VariationId);
+ _mockCmabClient.VerifyAll();
+ }
+
+ [Test]
+ public void FetchesNewDecisionWhenHashDiffers()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } }
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } });
+
+ var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID);
+ _cmabCache.Save(cacheKey, new CmabCacheEntry
+ {
+ AttributesHash = "different_hash",
+ CmabUuid = "uuid-old",
+ VariationId = "varOld"
+ });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.Is>(attrs => attrs.Count == 1 && (int)attrs["age"] == 25),
+ It.IsAny(),
+ It.IsAny())).Returns("varUpdated");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null);
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varUpdated", decision.VariationId);
+ var cachedEntry = _cmabCache.Lookup(cacheKey);
+ Assert.IsNotNull(cachedEntry);
+ Assert.AreEqual("varUpdated", cachedEntry.VariationId);
+ Assert.AreEqual(decision.CmabUuid, cachedEntry.CmabUuid);
+ _mockCmabClient.VerifyAll();
+ }
+
+ [Test]
+ public void FiltersAttributesBeforeCallingClient()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID, LOCATION_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } },
+ { LOCATION_ATTRIBUTE_ID, new AttributeEntity { Id = LOCATION_ATTRIBUTE_ID, Key = "location" } }
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID, new Dictionary
+ {
+ { "age", 25 },
+ { "location", "USA" },
+ { "extra", "value" }
+ });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.Is>(attrs => attrs.Count == 2 &&
+ (int)attrs["age"] == 25 &&
+ (string)attrs["location"] == "USA" &&
+ !attrs.ContainsKey("extra")),
+ It.IsAny(),
+ It.IsAny())).Returns("varFiltered");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null);
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varFiltered", decision.VariationId);
+ _mockCmabClient.VerifyAll();
+ }
+
+ [Test]
+ public void HandlesMissingCmabConfiguration()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, null);
+ var attributeMap = new Dictionary();
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.Is>(attrs => attrs.Count == 0),
+ It.IsAny(),
+ It.IsAny())).Returns("varDefault");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null);
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varDefault", decision.VariationId);
+ _mockCmabClient.VerifyAll();
+ }
+
+ [Test]
+ public void AttributeHashIsStableRegardlessOfOrder()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID, LOCATION_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "a" } },
+ { LOCATION_ATTRIBUTE_ID, new AttributeEntity { Id = LOCATION_ATTRIBUTE_ID, Key = "b" } }
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+
+ var firstContext = CreateUserContext(TEST_USER_ID, new Dictionary
+ {
+ { "b", 2 },
+ { "a", 1 }
+ });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).Returns("varStable");
+
+ var firstDecision = _cmabService.GetDecision(projectConfig, firstContext, TEST_RULE_ID, null);
+ Assert.IsNotNull(firstDecision);
+ Assert.AreEqual("varStable", firstDecision.VariationId);
+
+ var secondContext = CreateUserContext(TEST_USER_ID, new Dictionary
+ {
+ { "a", 1 },
+ { "b", 2 }
+ });
+
+ var secondDecision = _cmabService.GetDecision(projectConfig, secondContext, TEST_RULE_ID, null);
+
+ Assert.IsNotNull(secondDecision);
+ Assert.AreEqual("varStable", secondDecision.VariationId);
+ _mockCmabClient.Verify(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.IsAny>(), It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Test]
+ public void UsesExpectedCacheKeyFormat()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } }
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).Returns("varKey");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null);
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varKey", decision.VariationId);
+
+ var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID);
+ var cachedEntry = _cmabCache.Lookup(cacheKey);
+ Assert.IsNotNull(cachedEntry);
+ Assert.AreEqual(decision.CmabUuid, cachedEntry.CmabUuid);
+ }
+
+ private OptimizelyUserContext CreateUserContext(string userId, IDictionary attributes)
+ {
+ var userContext = _optimizely.CreateUserContext(userId);
+
+ foreach (var attr in attributes)
+ {
+ userContext.SetAttribute(attr.Key, attr.Value);
+ }
+
+ return userContext;
+ }
+
+ private static ProjectConfig CreateProjectConfig(string ruleId, Experiment experiment,
+ Dictionary attributeMap)
+ {
+ var mockConfig = new Mock();
+ var experimentMap = new Dictionary();
+ if (experiment != null)
+ {
+ experimentMap[ruleId] = experiment;
+ }
+
+ mockConfig.SetupGet(c => c.ExperimentIdMap).Returns(experimentMap);
+ mockConfig.SetupGet(c => c.AttributeIdMap).Returns(attributeMap ?? new Dictionary());
+ return mockConfig.Object;
+ }
+
+ private static Experiment CreateExperiment(string ruleId, List attributeIds)
+ {
+ return new Experiment
+ {
+ Id = ruleId,
+ Cmab = attributeIds == null ? null : new Entity.Cmab(attributeIds)
+ };
+ }
+
+ }
+}
diff --git a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
index 01469f77..1b0b882e 100644
--- a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
+++ b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
@@ -71,6 +71,7 @@
+
diff --git a/OptimizelySDK/Cmab/DefaultCmabService.cs b/OptimizelySDK/Cmab/DefaultCmabService.cs
new file mode 100644
index 00000000..2cdf18c3
--- /dev/null
+++ b/OptimizelySDK/Cmab/DefaultCmabService.cs
@@ -0,0 +1,261 @@
+/*
+* Copyright 2025, 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
+*
+* http://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;
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using Newtonsoft.Json;
+using OptimizelySDK;
+using OptimizelySDK.Entity;
+using OptimizelySDK.Logger;
+using OptimizelySDK.Odp;
+using OptimizelySDK.OptimizelyDecisions;
+using AttributeEntity = OptimizelySDK.Entity.Attribute;
+
+namespace OptimizelySDK.Cmab
+{
+ ///
+ /// Represents a CMAB decision response returned by the service.
+ ///
+ public class CmabDecision
+ {
+ ///
+ /// Initializes a new instance of the CmabDecision class.
+ ///
+ /// The variation ID assigned by the CMAB service.
+ /// The unique identifier for this CMAB decision.
+ public CmabDecision(string variationId, string cmabUuid)
+ {
+ VariationId = variationId;
+ CmabUuid = cmabUuid;
+ }
+
+ ///
+ /// Gets the variation ID assigned by the CMAB service.
+ ///
+ public string VariationId { get; }
+
+ ///
+ /// Gets the unique identifier for this CMAB decision.
+ ///
+ public string CmabUuid { get; }
+ }
+
+ ///
+ /// Represents a cached CMAB decision entry.
+ ///
+ public class CmabCacheEntry
+ {
+ ///
+ /// Gets or sets the hash of the filtered attributes used for this decision.
+ ///
+ public string AttributesHash { get; set; }
+
+ ///
+ /// Gets or sets the variation ID from the cached decision.
+ ///
+ public string VariationId { get; set; }
+
+ ///
+ /// Gets or sets the CMAB UUID from the cached decision.
+ ///
+ public string CmabUuid { get; set; }
+ }
+
+ ///
+ /// Default implementation of the CMAB decision service that handles caching and filtering.
+ /// Provides methods for retrieving CMAB decisions with intelligent caching based on user attributes.
+ ///
+ public class DefaultCmabService : ICmabService
+ {
+ private readonly LruCache _cmabCache;
+ private readonly ICmabClient _cmabClient;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the DefaultCmabService class.
+ ///
+ /// LRU cache for storing CMAB decisions.
+ /// Client for fetching decisions from the CMAB prediction service.
+ /// Optional logger for recording service operations.
+ public DefaultCmabService(LruCache cmabCache,
+ ICmabClient cmabClient,
+ ILogger logger = null)
+ {
+ _cmabCache = cmabCache;
+ _cmabClient = cmabClient;
+ _logger = logger ?? new NoOpLogger();
+ }
+
+ public CmabDecision GetDecision(ProjectConfig projectConfig,
+ OptimizelyUserContext userContext,
+ string ruleId,
+ OptimizelyDecideOption[] options = null)
+ {
+ var optionSet = options ?? new OptimizelyDecideOption[0];
+ var filteredAttributes = FilterAttributes(projectConfig, userContext, ruleId);
+
+ if (optionSet.Contains(OptimizelyDecideOption.IGNORE_CMAB_CACHE))
+ {
+ _logger.Log(LogLevel.DEBUG, "Ignoring CMAB cache.");
+ return FetchDecision(ruleId, userContext.GetUserId(), filteredAttributes);
+ }
+
+ if (optionSet.Contains(OptimizelyDecideOption.RESET_CMAB_CACHE))
+ {
+ _logger.Log(LogLevel.DEBUG, "Resetting CMAB cache.");
+ _cmabCache.Reset();
+ }
+
+ var cacheKey = GetCacheKey(userContext.GetUserId(), ruleId);
+
+ if (optionSet.Contains(OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE))
+ {
+ _logger.Log(LogLevel.DEBUG, "Invalidating user CMAB cache.");
+ _cmabCache.Remove(cacheKey);
+ }
+
+ var cachedValue = _cmabCache.Lookup(cacheKey);
+ var attributesHash = HashAttributes(filteredAttributes);
+
+ if (cachedValue != null)
+ {
+ if (string.Equals(cachedValue.AttributesHash, attributesHash, StringComparison.Ordinal))
+ {
+ return new CmabDecision(cachedValue.VariationId, cachedValue.CmabUuid);
+ }
+ else
+ {
+ _cmabCache.Remove(cacheKey);
+ }
+
+ }
+
+ var cmabDecision = FetchDecision(ruleId, userContext.GetUserId(), filteredAttributes);
+
+ _cmabCache.Save(cacheKey, new CmabCacheEntry
+ {
+ AttributesHash = attributesHash,
+ VariationId = cmabDecision.VariationId,
+ CmabUuid = cmabDecision.CmabUuid,
+ });
+
+ return cmabDecision;
+ }
+
+ ///
+ /// Fetches a new decision from the CMAB client and generates a unique UUID for tracking.
+ ///
+ /// The experiment/rule ID.
+ /// The user ID.
+ /// The filtered user attributes to send to the CMAB service.
+ /// A new CmabDecision with the assigned variation and generated UUID.
+ private CmabDecision FetchDecision(string ruleId,
+ string userId,
+ UserAttributes attributes)
+ {
+ var cmabUuid = Guid.NewGuid().ToString();
+ var variationId = _cmabClient.FetchDecision(ruleId, userId, attributes, cmabUuid);
+ return new CmabDecision(variationId, cmabUuid);
+ }
+
+ ///
+ /// Filters user attributes to include only those configured for the CMAB experiment.
+ ///
+ /// The project configuration containing attribute mappings.
+ /// The user context with all user attributes.
+ /// The experiment/rule ID to get CMAB attribute configuration for.
+ /// A UserAttributes object containing only the filtered attributes, or empty if no CMAB config exists.
+ ///
+ /// Only attributes specified in the experiment's CMAB configuration are included.
+ /// This ensures that cache invalidation is based only on relevant attributes.
+ ///
+ private UserAttributes FilterAttributes(ProjectConfig projectConfig,
+ OptimizelyUserContext userContext,
+ string ruleId)
+ {
+ var filtered = new UserAttributes();
+
+ if (projectConfig.ExperimentIdMap == null ||
+ !projectConfig.ExperimentIdMap.TryGetValue(ruleId, out var experiment) ||
+ experiment?.Cmab?.AttributeIds == null ||
+ experiment.Cmab.AttributeIds.Count == 0)
+ {
+ return filtered;
+ }
+
+ var userAttributes = userContext.GetAttributes() ?? new UserAttributes();
+ var attributeIdMap = projectConfig.AttributeIdMap ?? new Dictionary();
+
+ foreach (var attributeId in experiment.Cmab.AttributeIds)
+ {
+ if (attributeIdMap.TryGetValue(attributeId, out var attribute) &&
+ userAttributes.TryGetValue(attribute.Key, out var value))
+ {
+ filtered[attribute.Key] = value;
+ }
+ }
+
+ return filtered;
+ }
+
+ ///
+ /// Generates a cache key for storing and retrieving CMAB decisions.
+ ///
+ /// The user ID.
+ /// The experiment/rule ID.
+ /// A cache key string in the format: {userId.Length}-{userId}-{ruleId}
+ ///
+ /// The length prefix prevents key collisions between different user IDs that might appear
+ /// similar when concatenated (e.g., "12-abc-exp" vs "1-2abc-exp").
+ ///
+ internal static string GetCacheKey(string userId, string ruleId)
+ {
+ var normalizedUserId = userId ?? string.Empty;
+ return $"{normalizedUserId.Length}-{normalizedUserId}-{ruleId}";
+ }
+
+ ///
+ /// Computes an MD5 hash of the user attributes for cache validation.
+ ///
+ /// The user attributes to hash.
+ /// A hexadecimal MD5 hash string of the serialized attributes.
+ ///
+ /// Attributes are sorted by key before hashing to ensure consistent hashes regardless of
+ /// the order in which attributes are provided. This allows cache hits when the same attributes
+ /// are present in different orders.
+ ///
+ internal static string HashAttributes(UserAttributes attributes)
+ {
+ var ordered = attributes.OrderBy(kvp => kvp.Key).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
+ var serialized = JsonConvert.SerializeObject(ordered);
+
+ using (var md5 = MD5.Create())
+ {
+ var hashBytes = md5.ComputeHash(Encoding.UTF8.GetBytes(serialized));
+ var builder = new StringBuilder(hashBytes.Length * 2);
+ foreach (var b in hashBytes)
+ {
+ builder.Append(b.ToString("x2"));
+ }
+
+ return builder.ToString();
+ }
+ }
+ }
+}
diff --git a/OptimizelySDK/Cmab/ICmabService.cs b/OptimizelySDK/Cmab/ICmabService.cs
new file mode 100644
index 00000000..3b909295
--- /dev/null
+++ b/OptimizelySDK/Cmab/ICmabService.cs
@@ -0,0 +1,31 @@
+/*
+* Copyright 2025, 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
+*
+* http://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 OptimizelySDK.OptimizelyDecisions;
+
+namespace OptimizelySDK.Cmab
+{
+ ///
+ /// Contract for CMAB decision services.
+ ///
+ public interface ICmabService
+ {
+ CmabDecision GetDecision(ProjectConfig projectConfig,
+ OptimizelyUserContext userContext,
+ string ruleId,
+ OptimizelyDecideOption[] options);
+ }
+}
diff --git a/OptimizelySDK/OptimizelyDecisions/OptimizelyDecideOption.cs b/OptimizelySDK/OptimizelyDecisions/OptimizelyDecideOption.cs
index b0ec5307..1b7379ff 100644
--- a/OptimizelySDK/OptimizelyDecisions/OptimizelyDecideOption.cs
+++ b/OptimizelySDK/OptimizelyDecisions/OptimizelyDecideOption.cs
@@ -23,5 +23,8 @@ public enum OptimizelyDecideOption
IGNORE_USER_PROFILE_SERVICE,
INCLUDE_REASONS,
EXCLUDE_VARIABLES,
+ IGNORE_CMAB_CACHE,
+ RESET_CMAB_CACHE,
+ INVALIDATE_USER_CMAB_CACHE,
}
}
diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj
index ccd53f42..7091cf01 100644
--- a/OptimizelySDK/OptimizelySDK.csproj
+++ b/OptimizelySDK/OptimizelySDK.csproj
@@ -205,7 +205,9 @@
+
+