From ebe4ffafa4939b538fe6e72aa3aa2bef2c72de88 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Thu, 25 Jun 2026 08:35:25 -0700 Subject: [PATCH 1/8] [FSSDK-12813] Normalize decision event campaign_id, variation_id, and entity_id --- .../EventTests/EventFactoryTest.cs | 236 +++++++++++++++++- .../OptimizelySDK.Tests.csproj | 1 + .../UtilsTests/EventIdNormalizerTest.cs | 225 +++++++++++++++++ OptimizelySDK/Event/Builder/EventBuilder.cs | 15 +- OptimizelySDK/Event/EventFactory.cs | 21 +- OptimizelySDK/OptimizelySDK.csproj | 1 + OptimizelySDK/Utils/EventIdNormalizer.cs | 87 +++++++ 7 files changed, 576 insertions(+), 10 deletions(-) create mode 100644 OptimizelySDK.Tests/UtilsTests/EventIdNormalizerTest.cs create mode 100644 OptimizelySDK/Utils/EventIdNormalizer.cs diff --git a/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs b/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs index 8c839d67..199af95a 100644 --- a/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs +++ b/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs @@ -767,7 +767,10 @@ public void TestCreateImpressionEventRemovesInvalidAttributesFromPayloadRollout( { new Dictionary { - { "campaign_id", null }, + // FSSDK-12813: campaign_id was previously null when LayerId + // was missing; the normalizer substitutes experiment_id + // (string.Empty here). variation_id stays null when invalid. + { "campaign_id", string.Empty }, { "experiment_id", string.Empty }, { "variation_id", null }, { @@ -788,7 +791,9 @@ public void TestCreateImpressionEventRemovesInvalidAttributesFromPayloadRollout( { new Dictionary { - { "entity_id", null }, + // FSSDK-12813: entity_id (impression-event) follows the same + // rule as campaign_id and must match it byte-for-byte. + { "entity_id", string.Empty }, { "timestamp", timeStamp }, { "uuid", guid }, { "key", "campaign_activated" }, @@ -2759,5 +2764,232 @@ public void TestCreateConversionEventRemovesInvalidAttributesFromPayload() Guid.Parse(conversionEvent.UUID)); Assert.IsTrue(TestData.CompareObjects(expectedEvent, logEvent)); } + + // ====================================================================== + // FSSDK-12813: Decision-event id normalization end-to-end tests. + // + // These tests build ImpressionEvent payloads directly (bypassing + // ProjectConfig lookups) so we can exercise the normalization branches + // for each invalid-input variant uniformly across decision types + // (experiment, feature test, rollout, holdout) without per-type + // branching in the normalization path. + // ====================================================================== + + private static OptimizelySDK.Event.Entity.EventContext NormalizationTestEventContext() + { + return new OptimizelySDK.Event.Entity.EventContext.Builder() + .WithProjectId("7720880029") + .WithAccountId("1592310167") + .WithAnonymizeIP(false) + .WithRevision("15") + .Build(); + } + + private static ImpressionEvent BuildImpressionEvent( + string layerId, string experimentId, string variationId, string ruleType) + { + var experiment = new Experiment + { + Id = experimentId, + Key = "test_experiment", + LayerId = layerId, + }; + var variation = variationId == null + ? null + : new Variation { Id = variationId, Key = "v" }; + var metadata = new OptimizelySDK.Event.Entity.DecisionMetadata( + "test_flag", "test_experiment", ruleType, variation?.Key ?? string.Empty, true); + + return new ImpressionEvent.Builder() + .WithEventContext(NormalizationTestEventContext()) + .WithExperiment(experiment) + .WithVariation(variation) + .WithMetadata(metadata) + .WithUserId(TestUserId) + .WithVisitorAttributes(new OptimizelySDK.Event.Entity.VisitorAttribute[0]) + .Build(); + } + + // Re-serialize the LogEvent params to a JObject so we can navigate without + // having to know whether the underlying values are object[], JArray, Snapshot, + // or Dictionary instances. This mirrors what the wire payload looks like. + private static Newtonsoft.Json.Linq.JObject AsJson(LogEvent logEvent) + { + var json = Newtonsoft.Json.JsonConvert.SerializeObject(logEvent.Params); + return Newtonsoft.Json.Linq.JObject.Parse(json); + } + + private static Newtonsoft.Json.Linq.JObject ExtractDecisionJson(LogEvent logEvent) + { + var root = AsJson(logEvent); + return (Newtonsoft.Json.Linq.JObject) + root["visitors"][0]["snapshots"][0]["decisions"][0]; + } + + private static Newtonsoft.Json.Linq.JObject ExtractEventJson(LogEvent logEvent) + { + var root = AsJson(logEvent); + return (Newtonsoft.Json.Linq.JObject) + root["visitors"][0]["snapshots"][0]["events"][0]; + } + + + [Test] + public void TestNormalize_ValidNumericIds_PassThroughUnchanged() + { + // FSSDK-12813 happy path: valid numeric IDs flow through unchanged. + var impressionEvent = BuildImpressionEvent( + layerId: "7719770039", + experimentId: "1111111111", + variationId: "7722370027", + ruleType: "experiment"); + var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger); + + var decision = ExtractDecisionJson(logEvent); + Assert.AreEqual("7719770039", (string)decision["campaign_id"]); + Assert.AreEqual("1111111111", (string)decision["experiment_id"]); + Assert.AreEqual("7722370027", (string)decision["variation_id"]); + + var ev = ExtractEventJson(logEvent); + Assert.AreEqual("7719770039", (string)ev["entity_id"]); + Assert.AreEqual((string)decision["campaign_id"], (string)ev["entity_id"], + "entity_id must equal campaign_id byte-for-byte"); + } + + [Test] + public void TestNormalize_NullLayerId_CampaignIdFallsBackToExperimentId() + { + // FR-001/FR-002: campaign_id null -> experiment_id substituted. + var impressionEvent = BuildImpressionEvent( + layerId: null, + experimentId: "1111111111", + variationId: "7722370027", + ruleType: "experiment"); + var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger); + + var decision = ExtractDecisionJson(logEvent); + Assert.AreEqual("1111111111", (string)decision["campaign_id"]); + Assert.AreEqual((string)decision["campaign_id"], + (string)ExtractEventJson(logEvent)["entity_id"]); + } + + [Test] + public void TestNormalize_NonNumericLayerId_CampaignIdFallsBackToExperimentId() + { + // FR-001/FR-002: campaign_id non-numeric -> experiment_id substituted. + var impressionEvent = BuildImpressionEvent( + layerId: "not_numeric_layer", + experimentId: "1111111111", + variationId: "7722370027", + ruleType: "feature-test"); + var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger); + + var decision = ExtractDecisionJson(logEvent); + Assert.AreEqual("1111111111", (string)decision["campaign_id"]); + Assert.AreEqual((string)decision["campaign_id"], + (string)ExtractEventJson(logEvent)["entity_id"]); + } + + [Test] + public void TestNormalize_NonNumericVariationId_VariationIdBecomesNull() + { + // FR-003/FR-004: variation_id non-numeric -> null. + var impressionEvent = BuildImpressionEvent( + layerId: "7719770039", + experimentId: "1111111111", + variationId: "variation_a", + ruleType: "experiment"); + var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger); + + var decision = ExtractDecisionJson(logEvent); + Assert.AreEqual(Newtonsoft.Json.Linq.JTokenType.Null, decision["variation_id"].Type); + } + + [Test] + public void TestNormalize_EmptyVariationId_VariationIdBecomesNull() + { + var impressionEvent = BuildImpressionEvent( + layerId: "7719770039", + experimentId: "1111111111", + variationId: string.Empty, + ruleType: "experiment"); + var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger); + + var decision = ExtractDecisionJson(logEvent); + Assert.AreEqual(Newtonsoft.Json.Linq.JTokenType.Null, decision["variation_id"].Type); + } + + [Test] + public void TestNormalize_AppliedUniformlyAcrossRuleTypes() + { + // FR-005: rule applies uniformly to ALL decision types. Same invalid + // inputs must produce byte-equivalent wire output regardless of + // rule_type (experiment, feature-test, rollout, holdout). + var ruleTypes = new[] { "experiment", "feature-test", "rollout", "holdout" }; + string firstCampaignId = null; + Newtonsoft.Json.Linq.JTokenType firstVariationIdType = + Newtonsoft.Json.Linq.JTokenType.None; + string firstEntityId = null; + + foreach (var ruleType in ruleTypes) + { + var impressionEvent = BuildImpressionEvent( + layerId: "not_numeric", + experimentId: "1111111111", + variationId: "also_not_numeric", + ruleType: ruleType); + var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger); + + var decision = ExtractDecisionJson(logEvent); + var ev = ExtractEventJson(logEvent); + + var campaignId = (string)decision["campaign_id"]; + var variationIdType = decision["variation_id"].Type; + var entityId = (string)ev["entity_id"]; + + Assert.AreEqual("1111111111", campaignId, "rule_type=" + ruleType); + Assert.AreEqual(Newtonsoft.Json.Linq.JTokenType.Null, variationIdType, + "rule_type=" + ruleType); + Assert.AreEqual(campaignId, entityId, + "entity_id must equal campaign_id (rule_type=" + ruleType + ")"); + + if (firstCampaignId == null) + { + firstCampaignId = campaignId; + firstVariationIdType = variationIdType; + firstEntityId = entityId; + } + else + { + Assert.AreEqual(firstCampaignId, campaignId, + "campaign_id must be uniform across rule types"); + Assert.AreEqual(firstVariationIdType, variationIdType, + "variation_id must be uniform across rule types"); + Assert.AreEqual(firstEntityId, entityId, + "entity_id must be uniform across rule types"); + } + } + } + + [Test] + public void TestNormalize_DoesNotDropEventDispatch() + { + // FR-006: do not drop, defer, or fail event dispatch. + // Even when every id is invalid, a LogEvent must still be produced. + var impressionEvent = BuildImpressionEvent( + layerId: null, + experimentId: string.Empty, + variationId: null, + ruleType: "rollout"); + var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger); + + Assert.IsNotNull(logEvent, "Event dispatch must NOT be dropped during normalization"); + var decision = ExtractDecisionJson(logEvent); + Assert.AreEqual(string.Empty, (string)decision["campaign_id"]); + Assert.AreEqual(string.Empty, (string)decision["experiment_id"]); + Assert.AreEqual(Newtonsoft.Json.Linq.JTokenType.Null, decision["variation_id"].Type); + Assert.AreEqual(string.Empty, + (string)ExtractEventJson(logEvent)["entity_id"]); + } } } diff --git a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj index 98db5acf..11283bfe 100644 --- a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj +++ b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj @@ -121,6 +121,7 @@ + diff --git a/OptimizelySDK.Tests/UtilsTests/EventIdNormalizerTest.cs b/OptimizelySDK.Tests/UtilsTests/EventIdNormalizerTest.cs new file mode 100644 index 00000000..7bbae5d7 --- /dev/null +++ b/OptimizelySDK.Tests/UtilsTests/EventIdNormalizerTest.cs @@ -0,0 +1,225 @@ +/* + * 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 + * + * 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 NUnit.Framework; +using OptimizelySDK.Utils; + +namespace OptimizelySDK.Tests.UtilsTests +{ + /// + /// Unit tests for FSSDK-12813 decision-event id normalization rules. + /// + [TestFixture] + public class EventIdNormalizerTest + { + // ---------- IsNumericIdString ---------- + + [Test] + public void IsNumericIdString_DigitString_ReturnsTrue() + { + Assert.IsTrue(EventIdNormalizer.IsNumericIdString("7719770039")); + } + + [Test] + public void IsNumericIdString_SingleDigit_ReturnsTrue() + { + Assert.IsTrue(EventIdNormalizer.IsNumericIdString("0")); + Assert.IsTrue(EventIdNormalizer.IsNumericIdString("9")); + } + + [Test] + public void IsNumericIdString_LeadingZeros_ReturnsTrue() + { + // Per FSSDK-12813 spec: leading zeros are allowed. + Assert.IsTrue(EventIdNormalizer.IsNumericIdString("0001")); + Assert.IsTrue(EventIdNormalizer.IsNumericIdString("0000")); + } + + [Test] + public void IsNumericIdString_Null_ReturnsFalse() + { + Assert.IsFalse(EventIdNormalizer.IsNumericIdString(null)); + } + + [Test] + public void IsNumericIdString_Empty_ReturnsFalse() + { + Assert.IsFalse(EventIdNormalizer.IsNumericIdString(string.Empty)); + } + + [Test] + public void IsNumericIdString_Whitespace_ReturnsFalse() + { + Assert.IsFalse(EventIdNormalizer.IsNumericIdString(" ")); + Assert.IsFalse(EventIdNormalizer.IsNumericIdString("\t")); + Assert.IsFalse(EventIdNormalizer.IsNumericIdString("123 ")); + Assert.IsFalse(EventIdNormalizer.IsNumericIdString(" 123")); + Assert.IsFalse(EventIdNormalizer.IsNumericIdString("12 3")); + } + + [Test] + public void IsNumericIdString_AlphaOrAlphanumeric_ReturnsFalse() + { + Assert.IsFalse(EventIdNormalizer.IsNumericIdString("abc")); + Assert.IsFalse(EventIdNormalizer.IsNumericIdString("variation_a")); + Assert.IsFalse(EventIdNormalizer.IsNumericIdString("exp_42")); + Assert.IsFalse(EventIdNormalizer.IsNumericIdString("123abc")); + Assert.IsFalse(EventIdNormalizer.IsNumericIdString("abc123")); + } + + [Test] + public void IsNumericIdString_SignedOrDecimal_ReturnsFalse() + { + Assert.IsFalse(EventIdNormalizer.IsNumericIdString("-123")); + Assert.IsFalse(EventIdNormalizer.IsNumericIdString("+123")); + Assert.IsFalse(EventIdNormalizer.IsNumericIdString("12.3")); + Assert.IsFalse(EventIdNormalizer.IsNumericIdString("1e5")); + Assert.IsFalse(EventIdNormalizer.IsNumericIdString("0x1F")); + } + + // ---------- NormalizeCampaignId ---------- + + [Test] + public void NormalizeCampaignId_ValidNumeric_ReturnsAsIs() + { + Assert.AreEqual("7719770039", + EventIdNormalizer.NormalizeCampaignId("7719770039", "1111111111")); + } + + [Test] + public void NormalizeCampaignId_Null_SubstitutesExperimentId() + { + Assert.AreEqual("1111111111", + EventIdNormalizer.NormalizeCampaignId(null, "1111111111")); + } + + [Test] + public void NormalizeCampaignId_Empty_SubstitutesExperimentId() + { + Assert.AreEqual("1111111111", + EventIdNormalizer.NormalizeCampaignId(string.Empty, "1111111111")); + } + + [Test] + public void NormalizeCampaignId_NonNumeric_SubstitutesExperimentId() + { + Assert.AreEqual("1111111111", + EventIdNormalizer.NormalizeCampaignId("variation_a", "1111111111")); + Assert.AreEqual("1111111111", + EventIdNormalizer.NormalizeCampaignId("exp_42", "1111111111")); + Assert.AreEqual("1111111111", + EventIdNormalizer.NormalizeCampaignId("abc", "1111111111")); + } + + [Test] + public void NormalizeCampaignId_Whitespace_SubstitutesExperimentId() + { + Assert.AreEqual("1111111111", + EventIdNormalizer.NormalizeCampaignId(" 7719770039 ", "1111111111")); + Assert.AreEqual("1111111111", + EventIdNormalizer.NormalizeCampaignId(" ", "1111111111")); + } + + [Test] + public void NormalizeCampaignId_InvalidWithEmptyExperimentId_ReturnsEmpty() + { + // Mirrors the rollout case where activatedExperiment is null and we want + // wire output `""` rather than `null` (matches existing test contract). + Assert.AreEqual(string.Empty, + EventIdNormalizer.NormalizeCampaignId(null, string.Empty)); + } + + [Test] + public void NormalizeCampaignId_SubstituteNotRecursivelyNormalized() + { + // The normalizer returns experimentId AS-IS when campaignId is invalid. + // This matches the cross-SDK contract and lets callers see the exact substitute. + Assert.AreEqual("not_numeric_either", + EventIdNormalizer.NormalizeCampaignId(null, "not_numeric_either")); + } + + // ---------- NormalizeVariationId ---------- + + [Test] + public void NormalizeVariationId_ValidNumeric_ReturnsAsIs() + { + Assert.AreEqual("7722370027", + EventIdNormalizer.NormalizeVariationId("7722370027")); + } + + [Test] + public void NormalizeVariationId_Null_ReturnsNull() + { + Assert.IsNull(EventIdNormalizer.NormalizeVariationId(null)); + } + + [Test] + public void NormalizeVariationId_Empty_ReturnsNull() + { + Assert.IsNull(EventIdNormalizer.NormalizeVariationId(string.Empty)); + } + + [Test] + public void NormalizeVariationId_NonNumeric_ReturnsNull() + { + Assert.IsNull(EventIdNormalizer.NormalizeVariationId("variation_a")); + Assert.IsNull(EventIdNormalizer.NormalizeVariationId("v1")); + Assert.IsNull(EventIdNormalizer.NormalizeVariationId("abc")); + } + + [Test] + public void NormalizeVariationId_Whitespace_ReturnsNull() + { + Assert.IsNull(EventIdNormalizer.NormalizeVariationId(" ")); + Assert.IsNull(EventIdNormalizer.NormalizeVariationId(" 123")); + Assert.IsNull(EventIdNormalizer.NormalizeVariationId("12 3")); + } + + [Test] + public void NormalizeVariationId_SignedOrDecimal_ReturnsNull() + { + Assert.IsNull(EventIdNormalizer.NormalizeVariationId("-123")); + Assert.IsNull(EventIdNormalizer.NormalizeVariationId("12.3")); + } + + // ---------- Cross-field invariant: entity_id == campaign_id ---------- + + [Test] + public void EntityIdFollowsSameRuleAsCampaignId() + { + // FR-009: entity_id (impression events) uses the same normalization rule + // as campaign_id. Callers should pass the SAME inputs to NormalizeCampaignId + // for both fields to ensure byte-equivalence. + var inputs = new[] { + new { CampaignId = "7719770039", ExperimentId = "1111111111" }, + new { CampaignId = (string)null, ExperimentId = "1111111111" }, + new { CampaignId = string.Empty, ExperimentId = "1111111111" }, + new { CampaignId = "not_numeric", ExperimentId = "1111111111" }, + new { CampaignId = " ", ExperimentId = string.Empty }, + }; + + foreach (var input in inputs) + { + var campaignId = EventIdNormalizer.NormalizeCampaignId( + input.CampaignId, input.ExperimentId); + var entityId = EventIdNormalizer.NormalizeCampaignId( + input.CampaignId, input.ExperimentId); + Assert.AreEqual(campaignId, entityId, + "entity_id must equal campaign_id byte-for-byte for the same impression event"); + } + } + } +} diff --git a/OptimizelySDK/Event/Builder/EventBuilder.cs b/OptimizelySDK/Event/Builder/EventBuilder.cs index 0dd4562a..520e2d44 100644 --- a/OptimizelySDK/Event/Builder/EventBuilder.cs +++ b/OptimizelySDK/Event/Builder/EventBuilder.cs @@ -130,13 +130,20 @@ string variationId { var impressionEvent = new Dictionary(); + // FSSDK-12813: Normalize campaign_id, variation_id, and entity_id (impression + // events only) uniformly across all decision types. Same rules as EventFactory. + var experimentId = experiment?.Id ?? string.Empty; + var normalizedCampaignId = EventIdNormalizer.NormalizeCampaignId( + experiment?.LayerId, experimentId); + var normalizedVariationId = EventIdNormalizer.NormalizeVariationId(variationId); + var decisions = new object[] { new Dictionary { - { Params.CAMPAIGN_ID, experiment?.LayerId }, - { Params.EXPERIMENT_ID, experiment?.Id ?? string.Empty }, - { Params.VARIATION_ID, variationId }, + { Params.CAMPAIGN_ID, normalizedCampaignId }, + { Params.EXPERIMENT_ID, experimentId }, + { Params.VARIATION_ID, normalizedVariationId }, }, }; @@ -145,7 +152,7 @@ string variationId { new Dictionary { - { "entity_id", experiment?.LayerId }, + { "entity_id", normalizedCampaignId }, { "timestamp", DateTimeUtils.SecondsSince1970 * 1000 }, { "key", ACTIVATE_EVENT_KEY }, { "uuid", Guid.NewGuid() }, diff --git a/OptimizelySDK/Event/EventFactory.cs b/OptimizelySDK/Event/EventFactory.cs index 771e0f39..008aa539 100644 --- a/OptimizelySDK/Event/EventFactory.cs +++ b/OptimizelySDK/Event/EventFactory.cs @@ -145,13 +145,26 @@ private static Visitor CreateVisitor(ImpressionEvent impressionEvent) return null; } - var decision = new Decision(impressionEvent.Experiment?.LayerId, - impressionEvent.Experiment?.Id ?? string.Empty, - impressionEvent.Variation?.Id, + // FSSDK-12813: Normalize decision-event identifiers uniformly across all + // decision types (experiment, feature test, rollout, holdout). The + // normalization MUST NOT log, warn, throw, drop, or defer event dispatch. + // - campaign_id: if invalid (null/empty/non-numeric), substitute experiment_id. + // - variation_id: if invalid, substitute null. + // - entity_id (impression events): same rule as campaign_id; MUST equal + // the normalized campaign_id byte-for-byte for the same impression event. + var experimentId = impressionEvent.Experiment?.Id ?? string.Empty; + var normalizedCampaignId = EventIdNormalizer.NormalizeCampaignId( + impressionEvent.Experiment?.LayerId, experimentId); + var normalizedVariationId = EventIdNormalizer.NormalizeVariationId( + impressionEvent.Variation?.Id); + + var decision = new Decision(normalizedCampaignId, + experimentId, + normalizedVariationId, impressionEvent.Metadata); var snapshotEvent = new SnapshotEvent.Builder().WithUUID(impressionEvent.UUID). - WithEntityId(impressionEvent.Experiment?.LayerId). + WithEntityId(normalizedCampaignId). WithKey(ACTIVATE_EVENT_KEY). WithTimeStamp(impressionEvent.Timestamp). Build(); diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj index a0ae7ba4..ddf58187 100644 --- a/OptimizelySDK/OptimizelySDK.csproj +++ b/OptimizelySDK/OptimizelySDK.csproj @@ -180,6 +180,7 @@ + diff --git a/OptimizelySDK/Utils/EventIdNormalizer.cs b/OptimizelySDK/Utils/EventIdNormalizer.cs new file mode 100644 index 00000000..abb8ff9c --- /dev/null +++ b/OptimizelySDK/Utils/EventIdNormalizer.cs @@ -0,0 +1,87 @@ +/* + * 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 + * + * 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. + */ + +namespace OptimizelySDK.Utils +{ + /// + /// Normalizes decision-event identifier fields (campaign_id, variation_id, entity_id) + /// per FSSDK-12813 so that the wire output is byte-equivalent across SDKs for the + /// same input regardless of decision type (experiment, feature test, rollout, holdout). + /// + /// "Numeric string" definition: a non-empty string consisting entirely of decimal + /// digits [0-9]. Leading zeros are allowed. Whitespace, signs, decimals and + /// exponents are INVALID. + /// + /// Rules: + /// - campaign_id (and impression-event entity_id): if invalid, substitute the + /// provided experiment_id (which may itself be invalid; callers MUST normalize + /// experiment_id separately if they want a guarantee here). + /// - variation_id: if invalid, substitute null. + /// + /// This normalization MUST NOT log, warn, throw, drop, or defer event dispatch. + /// + internal static class EventIdNormalizer + { + /// + /// Returns true if is a non-empty string consisting + /// entirely of decimal digits [0-9]. Leading zeros are allowed. + /// + internal static bool IsNumericIdString(string value) + { + if (value == null) + { + return false; + } + + if (value.Length == 0) + { + return false; + } + + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + if (c < '0' || c > '9') + { + return false; + } + } + + return true; + } + + /// + /// Normalize a campaign_id (or impression-event entity_id, which follows the same rule). + /// If is a valid numeric string, return it unchanged. + /// Otherwise substitute . The returned value is + /// returned as-is (NOT recursively normalized) so callers see the exact substitute + /// they passed in, matching the cross-SDK contract. + /// + internal static string NormalizeCampaignId(string campaignId, string experimentId) + { + return IsNumericIdString(campaignId) ? campaignId : experimentId; + } + + /// + /// Normalize a variation_id. If is a valid numeric + /// string, return it unchanged. Otherwise return null. + /// + internal static string NormalizeVariationId(string variationId) + { + return IsNumericIdString(variationId) ? variationId : null; + } + } +} From 6766d2e864ffac4cf76dca55a1dec22139486f12 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Thu, 25 Jun 2026 11:12:02 -0700 Subject: [PATCH 2/8] [FSSDK-12813] Relax campaign_id/entity_id validation to non-empty string per updated spec --- .../EventTests/EventFactoryTest.cs | 67 +++++++++++-- .../UtilsTests/EventIdNormalizerTest.cs | 94 +++++++++++++++---- OptimizelySDK/Event/Builder/EventBuilder.cs | 5 +- OptimizelySDK/Event/EventFactory.cs | 6 +- OptimizelySDK/Utils/EventIdNormalizer.cs | 48 +++++++--- 5 files changed, 179 insertions(+), 41 deletions(-) diff --git a/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs b/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs index 199af95a..b138e59d 100644 --- a/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs +++ b/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs @@ -2874,11 +2874,31 @@ public void TestNormalize_NullLayerId_CampaignIdFallsBackToExperimentId() } [Test] - public void TestNormalize_NonNumericLayerId_CampaignIdFallsBackToExperimentId() + public void TestNormalize_OpaqueLayerId_CampaignIdPassesThroughUnchanged() { - // FR-001/FR-002: campaign_id non-numeric -> experiment_id substituted. + // FSSDK-12813 (relaxed contract): campaign_id accepts any non-empty string, + // including opaque IDs like "default-12345" or "layer_abc". The + // experiment_id fallback fires ONLY when campaign_id is null or "". var impressionEvent = BuildImpressionEvent( - layerId: "not_numeric_layer", + layerId: "default-12345", + experimentId: "1111111111", + variationId: "7722370027", + ruleType: "feature-test"); + var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger); + + var decision = ExtractDecisionJson(logEvent); + Assert.AreEqual("default-12345", (string)decision["campaign_id"]); + Assert.AreEqual((string)decision["campaign_id"], + (string)ExtractEventJson(logEvent)["entity_id"], + "entity_id must equal campaign_id byte-for-byte"); + } + + [Test] + public void TestNormalize_EmptyLayerId_CampaignIdFallsBackToExperimentId() + { + // FR-001/FR-002: empty-string campaign_id -> experiment_id substituted. + var impressionEvent = BuildImpressionEvent( + layerId: string.Empty, experimentId: "1111111111", variationId: "7722370027", ruleType: "feature-test"); @@ -2922,9 +2942,14 @@ public void TestNormalize_EmptyVariationId_VariationIdBecomesNull() [Test] public void TestNormalize_AppliedUniformlyAcrossRuleTypes() { - // FR-005: rule applies uniformly to ALL decision types. Same invalid - // inputs must produce byte-equivalent wire output regardless of - // rule_type (experiment, feature-test, rollout, holdout). + // FR-005: rule applies uniformly to ALL decision types. Same inputs + // must produce byte-equivalent wire output regardless of rule_type + // (experiment, feature-test, rollout, holdout). + // + // Under the FSSDK-12813 relaxed contract: + // - opaque non-empty campaign_id ("layer_abc") passes through unchanged. + // - non-numeric variation_id ("also_not_numeric") falls back to null + // (variation_id retains the strict numeric-string contract). var ruleTypes = new[] { "experiment", "feature-test", "rollout", "holdout" }; string firstCampaignId = null; Newtonsoft.Json.Linq.JTokenType firstVariationIdType = @@ -2934,7 +2959,7 @@ public void TestNormalize_AppliedUniformlyAcrossRuleTypes() foreach (var ruleType in ruleTypes) { var impressionEvent = BuildImpressionEvent( - layerId: "not_numeric", + layerId: "layer_abc", experimentId: "1111111111", variationId: "also_not_numeric", ruleType: ruleType); @@ -2947,7 +2972,7 @@ public void TestNormalize_AppliedUniformlyAcrossRuleTypes() var variationIdType = decision["variation_id"].Type; var entityId = (string)ev["entity_id"]; - Assert.AreEqual("1111111111", campaignId, "rule_type=" + ruleType); + Assert.AreEqual("layer_abc", campaignId, "rule_type=" + ruleType); Assert.AreEqual(Newtonsoft.Json.Linq.JTokenType.Null, variationIdType, "rule_type=" + ruleType); Assert.AreEqual(campaignId, entityId, @@ -2971,6 +2996,32 @@ public void TestNormalize_AppliedUniformlyAcrossRuleTypes() } } + [Test] + public void TestNormalize_NullLayerIdAppliedUniformlyAcrossRuleTypes() + { + // Companion to TestNormalize_AppliedUniformlyAcrossRuleTypes: exercises + // the fallback branch (null campaign_id) uniformly across rule types. + var ruleTypes = new[] { "experiment", "feature-test", "rollout", "holdout" }; + foreach (var ruleType in ruleTypes) + { + var impressionEvent = BuildImpressionEvent( + layerId: null, + experimentId: "1111111111", + variationId: null, + ruleType: ruleType); + var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger); + + var decision = ExtractDecisionJson(logEvent); + var ev = ExtractEventJson(logEvent); + Assert.AreEqual("1111111111", (string)decision["campaign_id"], + "rule_type=" + ruleType); + Assert.AreEqual((string)decision["campaign_id"], (string)ev["entity_id"], + "entity_id must equal campaign_id (rule_type=" + ruleType + ")"); + Assert.AreEqual(Newtonsoft.Json.Linq.JTokenType.Null, + decision["variation_id"].Type, "rule_type=" + ruleType); + } + } + [Test] public void TestNormalize_DoesNotDropEventDispatch() { diff --git a/OptimizelySDK.Tests/UtilsTests/EventIdNormalizerTest.cs b/OptimizelySDK.Tests/UtilsTests/EventIdNormalizerTest.cs index 7bbae5d7..cb6b3969 100644 --- a/OptimizelySDK.Tests/UtilsTests/EventIdNormalizerTest.cs +++ b/OptimizelySDK.Tests/UtilsTests/EventIdNormalizerTest.cs @@ -21,10 +21,55 @@ namespace OptimizelySDK.Tests.UtilsTests { /// /// Unit tests for FSSDK-12813 decision-event id normalization rules. + /// + /// Two distinct validity definitions are exercised here: + /// - IsNonEmptyString: campaign_id / entity_id contract (any non-empty string OK, + /// including opaque IDs like "default-12345"). + /// - IsNumericIdString: variation_id contract (decimal digits only). /// [TestFixture] public class EventIdNormalizerTest { + // ---------- IsNonEmptyString ---------- + + [Test] + public void IsNonEmptyString_NumericString_ReturnsTrue() + { + Assert.IsTrue(EventIdNormalizer.IsNonEmptyString("7719770039")); + } + + [Test] + public void IsNonEmptyString_OpaqueString_ReturnsTrue() + { + // FSSDK-12813 relaxed contract: any non-empty string is valid for + // campaign_id / entity_id. Opaque and prefixed IDs pass through. + Assert.IsTrue(EventIdNormalizer.IsNonEmptyString("default-12345")); + Assert.IsTrue(EventIdNormalizer.IsNonEmptyString("layer_abc")); + Assert.IsTrue(EventIdNormalizer.IsNonEmptyString("abc")); + Assert.IsTrue(EventIdNormalizer.IsNonEmptyString("exp_42")); + } + + [Test] + public void IsNonEmptyString_Whitespace_ReturnsTrue() + { + // Whitespace is non-empty content. The relaxed contract does not + // re-validate character content beyond length >= 1. + Assert.IsTrue(EventIdNormalizer.IsNonEmptyString(" ")); + Assert.IsTrue(EventIdNormalizer.IsNonEmptyString("\t")); + } + + [Test] + public void IsNonEmptyString_Null_ReturnsFalse() + { + Assert.IsFalse(EventIdNormalizer.IsNonEmptyString(null)); + } + + [Test] + public void IsNonEmptyString_Empty_ReturnsFalse() + { + Assert.IsFalse(EventIdNormalizer.IsNonEmptyString(string.Empty)); + } + // ---------- IsNumericIdString ---------- [Test] @@ -100,37 +145,45 @@ public void NormalizeCampaignId_ValidNumeric_ReturnsAsIs() } [Test] - public void NormalizeCampaignId_Null_SubstitutesExperimentId() + public void NormalizeCampaignId_OpaqueString_ReturnsAsIs() { - Assert.AreEqual("1111111111", - EventIdNormalizer.NormalizeCampaignId(null, "1111111111")); + // FSSDK-12813 relaxed contract: any non-empty string is valid for + // campaign_id. Opaque IDs pass through unchanged — no fallback. + Assert.AreEqual("default-12345", + EventIdNormalizer.NormalizeCampaignId("default-12345", "1111111111")); + Assert.AreEqual("layer_abc", + EventIdNormalizer.NormalizeCampaignId("layer_abc", "1111111111")); + Assert.AreEqual("variation_a", + EventIdNormalizer.NormalizeCampaignId("variation_a", "1111111111")); + Assert.AreEqual("exp_42", + EventIdNormalizer.NormalizeCampaignId("exp_42", "1111111111")); + Assert.AreEqual("abc", + EventIdNormalizer.NormalizeCampaignId("abc", "1111111111")); } [Test] - public void NormalizeCampaignId_Empty_SubstitutesExperimentId() + public void NormalizeCampaignId_WhitespaceString_ReturnsAsIs() { - Assert.AreEqual("1111111111", - EventIdNormalizer.NormalizeCampaignId(string.Empty, "1111111111")); + // Whitespace strings are non-empty, so they pass through under the + // relaxed contract (fallback fires only on null or ""). + Assert.AreEqual(" 7719770039 ", + EventIdNormalizer.NormalizeCampaignId(" 7719770039 ", "1111111111")); + Assert.AreEqual(" ", + EventIdNormalizer.NormalizeCampaignId(" ", "1111111111")); } [Test] - public void NormalizeCampaignId_NonNumeric_SubstitutesExperimentId() + public void NormalizeCampaignId_Null_SubstitutesExperimentId() { Assert.AreEqual("1111111111", - EventIdNormalizer.NormalizeCampaignId("variation_a", "1111111111")); - Assert.AreEqual("1111111111", - EventIdNormalizer.NormalizeCampaignId("exp_42", "1111111111")); - Assert.AreEqual("1111111111", - EventIdNormalizer.NormalizeCampaignId("abc", "1111111111")); + EventIdNormalizer.NormalizeCampaignId(null, "1111111111")); } [Test] - public void NormalizeCampaignId_Whitespace_SubstitutesExperimentId() + public void NormalizeCampaignId_Empty_SubstitutesExperimentId() { Assert.AreEqual("1111111111", - EventIdNormalizer.NormalizeCampaignId(" 7719770039 ", "1111111111")); - Assert.AreEqual("1111111111", - EventIdNormalizer.NormalizeCampaignId(" ", "1111111111")); + EventIdNormalizer.NormalizeCampaignId(string.Empty, "1111111111")); } [Test] @@ -145,13 +198,14 @@ public void NormalizeCampaignId_InvalidWithEmptyExperimentId_ReturnsEmpty() [Test] public void NormalizeCampaignId_SubstituteNotRecursivelyNormalized() { - // The normalizer returns experimentId AS-IS when campaignId is invalid. + // The normalizer returns experimentId AS-IS when campaignId is empty/null. // This matches the cross-SDK contract and lets callers see the exact substitute. Assert.AreEqual("not_numeric_either", EventIdNormalizer.NormalizeCampaignId(null, "not_numeric_either")); } // ---------- NormalizeVariationId ---------- + // variation_id contract is UNCHANGED — still strict numeric-string only. [Test] public void NormalizeVariationId_ValidNumeric_ReturnsAsIs() @@ -203,11 +257,15 @@ public void EntityIdFollowsSameRuleAsCampaignId() // FR-009: entity_id (impression events) uses the same normalization rule // as campaign_id. Callers should pass the SAME inputs to NormalizeCampaignId // for both fields to ensure byte-equivalence. + // + // Under the relaxed contract (FSSDK-12813), non-empty opaque/whitespace + // strings pass through; only null and "" trigger the experiment_id fallback. var inputs = new[] { new { CampaignId = "7719770039", ExperimentId = "1111111111" }, new { CampaignId = (string)null, ExperimentId = "1111111111" }, new { CampaignId = string.Empty, ExperimentId = "1111111111" }, - new { CampaignId = "not_numeric", ExperimentId = "1111111111" }, + new { CampaignId = "default-12345", ExperimentId = "1111111111" }, + new { CampaignId = "layer_abc", ExperimentId = "1111111111" }, new { CampaignId = " ", ExperimentId = string.Empty }, }; diff --git a/OptimizelySDK/Event/Builder/EventBuilder.cs b/OptimizelySDK/Event/Builder/EventBuilder.cs index 520e2d44..1f165675 100644 --- a/OptimizelySDK/Event/Builder/EventBuilder.cs +++ b/OptimizelySDK/Event/Builder/EventBuilder.cs @@ -131,7 +131,10 @@ string variationId var impressionEvent = new Dictionary(); // FSSDK-12813: Normalize campaign_id, variation_id, and entity_id (impression - // events only) uniformly across all decision types. Same rules as EventFactory. + // events only) uniformly across all decision types. Same rules as EventFactory: + // campaign_id/entity_id fall back to experiment_id only when null or "" (any + // other non-empty string passes through); variation_id keeps the strict + // numeric-string contract and falls back to null otherwise. var experimentId = experiment?.Id ?? string.Empty; var normalizedCampaignId = EventIdNormalizer.NormalizeCampaignId( experiment?.LayerId, experimentId); diff --git a/OptimizelySDK/Event/EventFactory.cs b/OptimizelySDK/Event/EventFactory.cs index 008aa539..22df1fb2 100644 --- a/OptimizelySDK/Event/EventFactory.cs +++ b/OptimizelySDK/Event/EventFactory.cs @@ -148,8 +148,10 @@ private static Visitor CreateVisitor(ImpressionEvent impressionEvent) // FSSDK-12813: Normalize decision-event identifiers uniformly across all // decision types (experiment, feature test, rollout, holdout). The // normalization MUST NOT log, warn, throw, drop, or defer event dispatch. - // - campaign_id: if invalid (null/empty/non-numeric), substitute experiment_id. - // - variation_id: if invalid, substitute null. + // - campaign_id: if not a non-empty string (null or ""), substitute + // experiment_id. Any non-empty string value passes through unchanged + // (IDs may be opaque, e.g. "default-12345", "layer_abc"). + // - variation_id: if not a non-empty numeric string, substitute null. // - entity_id (impression events): same rule as campaign_id; MUST equal // the normalized campaign_id byte-for-byte for the same impression event. var experimentId = impressionEvent.Experiment?.Id ?? string.Empty; diff --git a/OptimizelySDK/Utils/EventIdNormalizer.cs b/OptimizelySDK/Utils/EventIdNormalizer.cs index abb8ff9c..1125b479 100644 --- a/OptimizelySDK/Utils/EventIdNormalizer.cs +++ b/OptimizelySDK/Utils/EventIdNormalizer.cs @@ -21,20 +21,43 @@ namespace OptimizelySDK.Utils /// per FSSDK-12813 so that the wire output is byte-equivalent across SDKs for the /// same input regardless of decision type (experiment, feature test, rollout, holdout). /// - /// "Numeric string" definition: a non-empty string consisting entirely of decimal - /// digits [0-9]. Leading zeros are allowed. Whitespace, signs, decimals and - /// exponents are INVALID. + /// Two distinct validity definitions apply: + /// + /// - "Non-empty string" (campaign_id, impression-event entity_id): a string value of + /// length >= 1 with any character content. Numeric ("12345"), prefixed + /// ("default-12345"), and opaque ("layer_abc") IDs are all valid. Only `null` and + /// the empty string `""` trigger the fallback. Whitespace-only strings (e.g. " ") + /// are non-empty strings and therefore PASS THROUGH unchanged per the relaxed + /// contract (the upstream datafile is expected to deliver well-formed string IDs). + /// + /// - "Numeric string" (variation_id only): a non-empty string consisting entirely of + /// decimal digits [0-9]. Leading zeros are allowed. Whitespace, signs, decimals + /// and exponents are INVALID and trigger the null fallback. /// /// Rules: - /// - campaign_id (and impression-event entity_id): if invalid, substitute the - /// provided experiment_id (which may itself be invalid; callers MUST normalize - /// experiment_id separately if they want a guarantee here). - /// - variation_id: if invalid, substitute null. + /// - campaign_id (and impression-event entity_id): if not a non-empty string, + /// substitute the provided experiment_id (which may itself be empty or null; + /// callers MUST normalize experiment_id separately if they want a guarantee here). + /// - variation_id: if not a non-empty numeric string, substitute null. + /// + /// Non-string types (raw number, boolean, object) are out of scope per the spec + /// — the upstream datafile producer is assumed to deliver string-typed (or null) + /// values for these three fields. /// /// This normalization MUST NOT log, warn, throw, drop, or defer event dispatch. /// internal static class EventIdNormalizer { + /// + /// Returns true if is a non-empty string (length >= 1). + /// Any character content is accepted — IDs may be opaque like "default-12345" + /// or "layer_abc". Only `null` and the empty string return false. + /// + internal static bool IsNonEmptyString(string value) + { + return value != null && value.Length > 0; + } + /// /// Returns true if is a non-empty string consisting /// entirely of decimal digits [0-9]. Leading zeros are allowed. @@ -65,14 +88,15 @@ internal static bool IsNumericIdString(string value) /// /// Normalize a campaign_id (or impression-event entity_id, which follows the same rule). - /// If is a valid numeric string, return it unchanged. - /// Otherwise substitute . The returned value is - /// returned as-is (NOT recursively normalized) so callers see the exact substitute - /// they passed in, matching the cross-SDK contract. + /// If is a non-empty string, return it unchanged + /// (any character content is accepted — numeric, prefixed, or opaque). + /// Otherwise (null or empty string) substitute . + /// The returned value is returned as-is (NOT recursively normalized) so callers + /// see the exact substitute they passed in, matching the cross-SDK contract. /// internal static string NormalizeCampaignId(string campaignId, string experimentId) { - return IsNumericIdString(campaignId) ? campaignId : experimentId; + return IsNonEmptyString(campaignId) ? campaignId : experimentId; } /// From 0a93727ebf8741f5ec9eecd42f579a4483510ee5 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Thu, 25 Jun 2026 14:44:35 -0700 Subject: [PATCH 3/8] [FSSDK-12813] Remove ticket references from code comments per cross-sdk guideline --- .../EventTests/EventFactoryTest.cs | 32 ++++++++----------- .../UtilsTests/EventIdNormalizerTest.cs | 16 ++++------ OptimizelySDK/Event/Builder/EventBuilder.cs | 5 --- OptimizelySDK/Event/EventFactory.cs | 9 ------ OptimizelySDK/Utils/EventIdNormalizer.cs | 4 +-- 5 files changed, 23 insertions(+), 43 deletions(-) diff --git a/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs b/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs index b138e59d..9a4f01aa 100644 --- a/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs +++ b/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs @@ -767,9 +767,8 @@ public void TestCreateImpressionEventRemovesInvalidAttributesFromPayloadRollout( { new Dictionary { - // FSSDK-12813: campaign_id was previously null when LayerId - // was missing; the normalizer substitutes experiment_id - // (string.Empty here). variation_id stays null when invalid. + // campaign_id falls back to experiment_id (string.Empty + // here) when LayerId is missing. { "campaign_id", string.Empty }, { "experiment_id", string.Empty }, { "variation_id", null }, @@ -791,8 +790,8 @@ public void TestCreateImpressionEventRemovesInvalidAttributesFromPayloadRollout( { new Dictionary { - // FSSDK-12813: entity_id (impression-event) follows the same - // rule as campaign_id and must match it byte-for-byte. + // entity_id mirrors campaign_id byte-for-byte for + // impression events. { "entity_id", string.Empty }, { "timestamp", timeStamp }, { "uuid", guid }, @@ -2766,7 +2765,7 @@ public void TestCreateConversionEventRemovesInvalidAttributesFromPayload() } // ====================================================================== - // FSSDK-12813: Decision-event id normalization end-to-end tests. + // Decision-event id normalization end-to-end tests. // // These tests build ImpressionEvent payloads directly (bypassing // ProjectConfig lookups) so we can exercise the normalization branches @@ -2837,7 +2836,7 @@ private static Newtonsoft.Json.Linq.JObject ExtractEventJson(LogEvent logEvent) [Test] public void TestNormalize_ValidNumericIds_PassThroughUnchanged() { - // FSSDK-12813 happy path: valid numeric IDs flow through unchanged. + // Happy path: valid numeric IDs flow through unchanged. var impressionEvent = BuildImpressionEvent( layerId: "7719770039", experimentId: "1111111111", @@ -2876,9 +2875,9 @@ public void TestNormalize_NullLayerId_CampaignIdFallsBackToExperimentId() [Test] public void TestNormalize_OpaqueLayerId_CampaignIdPassesThroughUnchanged() { - // FSSDK-12813 (relaxed contract): campaign_id accepts any non-empty string, - // including opaque IDs like "default-12345" or "layer_abc". The - // experiment_id fallback fires ONLY when campaign_id is null or "". + // campaign_id accepts any non-empty string, including opaque IDs + // like "default-12345" or "layer_abc". The experiment_id fallback + // fires ONLY when campaign_id is null or "". var impressionEvent = BuildImpressionEvent( layerId: "default-12345", experimentId: "1111111111", @@ -2942,14 +2941,11 @@ public void TestNormalize_EmptyVariationId_VariationIdBecomesNull() [Test] public void TestNormalize_AppliedUniformlyAcrossRuleTypes() { - // FR-005: rule applies uniformly to ALL decision types. Same inputs - // must produce byte-equivalent wire output regardless of rule_type - // (experiment, feature-test, rollout, holdout). - // - // Under the FSSDK-12813 relaxed contract: - // - opaque non-empty campaign_id ("layer_abc") passes through unchanged. - // - non-numeric variation_id ("also_not_numeric") falls back to null - // (variation_id retains the strict numeric-string contract). + // Rule applies uniformly to ALL decision types. Same inputs must + // produce byte-equivalent wire output regardless of rule_type + // (experiment, feature-test, rollout, holdout). Opaque non-empty + // campaign_id ("layer_abc") passes through; non-numeric + // variation_id falls back to null (strict numeric-string contract). var ruleTypes = new[] { "experiment", "feature-test", "rollout", "holdout" }; string firstCampaignId = null; Newtonsoft.Json.Linq.JTokenType firstVariationIdType = diff --git a/OptimizelySDK.Tests/UtilsTests/EventIdNormalizerTest.cs b/OptimizelySDK.Tests/UtilsTests/EventIdNormalizerTest.cs index cb6b3969..e866de4e 100644 --- a/OptimizelySDK.Tests/UtilsTests/EventIdNormalizerTest.cs +++ b/OptimizelySDK.Tests/UtilsTests/EventIdNormalizerTest.cs @@ -20,7 +20,7 @@ namespace OptimizelySDK.Tests.UtilsTests { /// - /// Unit tests for FSSDK-12813 decision-event id normalization rules. + /// Unit tests for decision-event id normalization rules. /// /// Two distinct validity definitions are exercised here: /// - IsNonEmptyString: campaign_id / entity_id contract (any non-empty string OK, @@ -41,8 +41,8 @@ public void IsNonEmptyString_NumericString_ReturnsTrue() [Test] public void IsNonEmptyString_OpaqueString_ReturnsTrue() { - // FSSDK-12813 relaxed contract: any non-empty string is valid for - // campaign_id / entity_id. Opaque and prefixed IDs pass through. + // Any non-empty string is valid for campaign_id / entity_id. + // Opaque and prefixed IDs pass through. Assert.IsTrue(EventIdNormalizer.IsNonEmptyString("default-12345")); Assert.IsTrue(EventIdNormalizer.IsNonEmptyString("layer_abc")); Assert.IsTrue(EventIdNormalizer.IsNonEmptyString("abc")); @@ -88,7 +88,7 @@ public void IsNumericIdString_SingleDigit_ReturnsTrue() [Test] public void IsNumericIdString_LeadingZeros_ReturnsTrue() { - // Per FSSDK-12813 spec: leading zeros are allowed. + // Leading zeros are allowed. Assert.IsTrue(EventIdNormalizer.IsNumericIdString("0001")); Assert.IsTrue(EventIdNormalizer.IsNumericIdString("0000")); } @@ -147,8 +147,8 @@ public void NormalizeCampaignId_ValidNumeric_ReturnsAsIs() [Test] public void NormalizeCampaignId_OpaqueString_ReturnsAsIs() { - // FSSDK-12813 relaxed contract: any non-empty string is valid for - // campaign_id. Opaque IDs pass through unchanged — no fallback. + // Any non-empty string is valid for campaign_id. Opaque IDs + // pass through unchanged — no fallback. Assert.AreEqual("default-12345", EventIdNormalizer.NormalizeCampaignId("default-12345", "1111111111")); Assert.AreEqual("layer_abc", @@ -256,9 +256,7 @@ public void EntityIdFollowsSameRuleAsCampaignId() { // FR-009: entity_id (impression events) uses the same normalization rule // as campaign_id. Callers should pass the SAME inputs to NormalizeCampaignId - // for both fields to ensure byte-equivalence. - // - // Under the relaxed contract (FSSDK-12813), non-empty opaque/whitespace + // for both fields to ensure byte-equivalence. Non-empty opaque/whitespace // strings pass through; only null and "" trigger the experiment_id fallback. var inputs = new[] { new { CampaignId = "7719770039", ExperimentId = "1111111111" }, diff --git a/OptimizelySDK/Event/Builder/EventBuilder.cs b/OptimizelySDK/Event/Builder/EventBuilder.cs index 1f165675..c66a07ec 100644 --- a/OptimizelySDK/Event/Builder/EventBuilder.cs +++ b/OptimizelySDK/Event/Builder/EventBuilder.cs @@ -130,11 +130,6 @@ string variationId { var impressionEvent = new Dictionary(); - // FSSDK-12813: Normalize campaign_id, variation_id, and entity_id (impression - // events only) uniformly across all decision types. Same rules as EventFactory: - // campaign_id/entity_id fall back to experiment_id only when null or "" (any - // other non-empty string passes through); variation_id keeps the strict - // numeric-string contract and falls back to null otherwise. var experimentId = experiment?.Id ?? string.Empty; var normalizedCampaignId = EventIdNormalizer.NormalizeCampaignId( experiment?.LayerId, experimentId); diff --git a/OptimizelySDK/Event/EventFactory.cs b/OptimizelySDK/Event/EventFactory.cs index 22df1fb2..6753b998 100644 --- a/OptimizelySDK/Event/EventFactory.cs +++ b/OptimizelySDK/Event/EventFactory.cs @@ -145,15 +145,6 @@ private static Visitor CreateVisitor(ImpressionEvent impressionEvent) return null; } - // FSSDK-12813: Normalize decision-event identifiers uniformly across all - // decision types (experiment, feature test, rollout, holdout). The - // normalization MUST NOT log, warn, throw, drop, or defer event dispatch. - // - campaign_id: if not a non-empty string (null or ""), substitute - // experiment_id. Any non-empty string value passes through unchanged - // (IDs may be opaque, e.g. "default-12345", "layer_abc"). - // - variation_id: if not a non-empty numeric string, substitute null. - // - entity_id (impression events): same rule as campaign_id; MUST equal - // the normalized campaign_id byte-for-byte for the same impression event. var experimentId = impressionEvent.Experiment?.Id ?? string.Empty; var normalizedCampaignId = EventIdNormalizer.NormalizeCampaignId( impressionEvent.Experiment?.LayerId, experimentId); diff --git a/OptimizelySDK/Utils/EventIdNormalizer.cs b/OptimizelySDK/Utils/EventIdNormalizer.cs index 1125b479..e1626cb8 100644 --- a/OptimizelySDK/Utils/EventIdNormalizer.cs +++ b/OptimizelySDK/Utils/EventIdNormalizer.cs @@ -18,8 +18,8 @@ namespace OptimizelySDK.Utils { /// /// Normalizes decision-event identifier fields (campaign_id, variation_id, entity_id) - /// per FSSDK-12813 so that the wire output is byte-equivalent across SDKs for the - /// same input regardless of decision type (experiment, feature test, rollout, holdout). + /// so that the wire output is byte-equivalent across SDKs for the same input + /// regardless of decision type (experiment, feature test, rollout, holdout). /// /// Two distinct validity definitions apply: /// From b5ff2dd0935e397e159f6a39cfce8820d2daf4df Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Thu, 25 Jun 2026 19:36:35 -0700 Subject: [PATCH 4/8] [FSSDK-12813] Fix CI build: register EventIdNormalizer in Net35/Net40, add missing using --- OptimizelySDK.Net35/OptimizelySDK.Net35.csproj | 3 +++ OptimizelySDK.Net40/OptimizelySDK.Net40.csproj | 3 +++ OptimizelySDK.Tests/EventTests/EventFactoryTest.cs | 1 + 3 files changed, 7 insertions(+) diff --git a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj index a8a704e0..86b5f282 100644 --- a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj +++ b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj @@ -178,6 +178,9 @@ Utils\ConfigParser.cs + + Utils\EventIdNormalizer.cs + Utils\EventTagUtils.cs diff --git a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj index 017e210c..0527ff75 100644 --- a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj +++ b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj @@ -180,6 +180,9 @@ Utils\ConditionParser.cs + + Utils\EventIdNormalizer.cs + Utils\EventTagUtils.cs diff --git a/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs b/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs index 9a4f01aa..fc1f7ab5 100644 --- a/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs +++ b/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs @@ -23,6 +23,7 @@ using OptimizelySDK.Entity; using OptimizelySDK.ErrorHandler; using OptimizelySDK.Event; +using OptimizelySDK.Event.Entity; using OptimizelySDK.Logger; using OptimizelySDK.Utils; From 9884511799c80e1782df685d148657115d37de0a Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Thu, 25 Jun 2026 19:51:52 -0700 Subject: [PATCH 5/8] [FSSDK-12813] Fix CI: use literal userId in static normalization helper --- OptimizelySDK.Tests/EventTests/EventFactoryTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs b/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs index fc1f7ab5..5c08e167 100644 --- a/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs +++ b/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs @@ -2805,7 +2805,7 @@ private static ImpressionEvent BuildImpressionEvent( .WithExperiment(experiment) .WithVariation(variation) .WithMetadata(metadata) - .WithUserId(TestUserId) + .WithUserId("testUserId") .WithVisitorAttributes(new OptimizelySDK.Event.Entity.VisitorAttribute[0]) .Build(); } From cdd82e2dd788201d936d74ebd82d13fa5f3029cf Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Thu, 25 Jun 2026 20:16:50 -0700 Subject: [PATCH 6/8] [FSSDK-12813] Register EventIdNormalizer in NetStandard16/20 csproj --- OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj | 1 + OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj | 3 +++ 2 files changed, 4 insertions(+) diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj index 18aef612..ede232e0 100644 --- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj +++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj @@ -60,6 +60,7 @@ + diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj index 841059bd..0d41cd24 100644 --- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj +++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj @@ -360,6 +360,9 @@ Utils\DecisionInfoTypes.cs + + Utils\EventIdNormalizer.cs + Utils\EventTagUtils.cs From 898ba7ca9757befaf92cda761becbe590b21478b Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Thu, 25 Jun 2026 20:30:16 -0700 Subject: [PATCH 7/8] [FSSDK-12813] Add 2026 to copyright year in changed files --- OptimizelySDK.Tests/EventTests/EventFactoryTest.cs | 2 +- OptimizelySDK/Event/Builder/EventBuilder.cs | 2 +- OptimizelySDK/Event/EventFactory.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs b/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs index 5c08e167..2c56ca8a 100644 --- a/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs +++ b/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs @@ -1,6 +1,6 @@ /** * - * Copyright 2019-2020, Optimizely and contributors + * Copyright 2019-2020, 2026, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/OptimizelySDK/Event/Builder/EventBuilder.cs b/OptimizelySDK/Event/Builder/EventBuilder.cs index c66a07ec..7e2b3c3e 100644 --- a/OptimizelySDK/Event/Builder/EventBuilder.cs +++ b/OptimizelySDK/Event/Builder/EventBuilder.cs @@ -1,5 +1,5 @@ /* - * Copyright 2017-2019, Optimizely + * Copyright 2017-2019, 2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/OptimizelySDK/Event/EventFactory.cs b/OptimizelySDK/Event/EventFactory.cs index 6753b998..f9788858 100644 --- a/OptimizelySDK/Event/EventFactory.cs +++ b/OptimizelySDK/Event/EventFactory.cs @@ -1,5 +1,5 @@ /* - * Copyright 2019-2020, Optimizely + * Copyright 2019-2020, 2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From 5fcf927e4168a0bbb8b607ff69002e34935158c9 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Fri, 26 Jun 2026 09:28:18 -0700 Subject: [PATCH 8/8] [FSSDK-12813] Trigger CI