From 74be7f9be0d60acb0cf9e28ae64c32232759f1eb Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:19:19 -0700 Subject: [PATCH] feat: Add the overrideAffected indicator to EvaluationReason Adds an OverrideAffected property and a WithOverrideAffected copy method to EvaluationReason, as defined by the OVERRIDE specification. The indicator is true when a flag override affected the evaluation, directly or transitively, and false by default. Equality and hash code include it. The JSON property "overrideAffected" is written only when true and read when present, so the wire format of ordinary reasons is unchanged. --- pkgs/shared/common/src/EvaluationDetail.cs | 59 +++++++++++--- .../common/src/Json/LdJsonConverters.cs | 12 +++ .../common/test/EvaluationDetailTest.cs | 76 +++++++++++++++++++ 3 files changed, 136 insertions(+), 11 deletions(-) diff --git a/pkgs/shared/common/src/EvaluationDetail.cs b/pkgs/shared/common/src/EvaluationDetail.cs index e27a8c1e5..36782cebc 100644 --- a/pkgs/shared/common/src/EvaluationDetail.cs +++ b/pkgs/shared/common/src/EvaluationDetail.cs @@ -70,11 +70,11 @@ public override int GetHashCode() => public struct EvaluationReason : IJsonSerializable { private static readonly EvaluationReason _offInstance = - new EvaluationReason(EvaluationReasonKind.Off, null, null, null, null, false, null); + new EvaluationReason(EvaluationReasonKind.Off, null, null, null, null, false, null, false); private static readonly EvaluationReason _fallthroughInstance = - new EvaluationReason(EvaluationReasonKind.Fallthrough, null, null, null, null, false, null); + new EvaluationReason(EvaluationReasonKind.Fallthrough, null, null, null, null, false, null, false); private static readonly EvaluationReason _targetMatchInstance = - new EvaluationReason(EvaluationReasonKind.TargetMatch, null, null, null, null, false, null); + new EvaluationReason(EvaluationReasonKind.TargetMatch, null, null, null, null, false, null, false); private readonly EvaluationReasonKind _kind; private readonly int? _ruleIndex; @@ -83,6 +83,7 @@ public struct EvaluationReason : IJsonSerializable private readonly EvaluationErrorKind? _errorKind; private readonly bool _inExperiment; private readonly BigSegmentsStatus? _bigSegmentsStatus; + private readonly bool _overrideAffected; /// /// An enum indicating the general category of the reason. @@ -130,6 +131,25 @@ public struct EvaluationReason : IJsonSerializable /// public BigSegmentsStatus? BigSegmentsStatus => _bigSegmentsStatus; + /// + /// Whether a flag override affected this evaluation, directly or transitively. + /// + /// + /// + /// This is true if the evaluated flag came from the SDK's override store. It is also true if a + /// prerequisite flag at any depth, or a segment read during the evaluation, came from that store. + /// Otherwise it is false. + /// + /// + /// In the JSON representation, the overrideAffected property appears only when this value + /// is true. + /// + /// + /// Flag overrides are currently experimental and subject to change. + /// + /// + public bool OverrideAffected => _overrideAffected; + internal EvaluationReason( EvaluationReasonKind kind, int? ruleIndex, @@ -137,7 +157,8 @@ internal EvaluationReason( string prereqKey, EvaluationErrorKind? errorKind, bool inExperiment, - BigSegmentsStatus? bigSegmentsStatus + BigSegmentsStatus? bigSegmentsStatus, + bool overrideAffected ) { _kind = kind; @@ -147,6 +168,7 @@ internal EvaluationReason( _errorKind = errorKind; _inExperiment = inExperiment; _bigSegmentsStatus = bigSegmentsStatus; + _overrideAffected = overrideAffected; } /// @@ -171,7 +193,7 @@ internal EvaluationReason( /// the unique rule ID /// a reason descriptor public static EvaluationReason RuleMatchReason(int ruleIndex, string ruleId) => - new EvaluationReason(EvaluationReasonKind.RuleMatch, ruleIndex, ruleId, null, null, false, null); + new EvaluationReason(EvaluationReasonKind.RuleMatch, ruleIndex, ruleId, null, null, false, null, false); /// /// Returns an EvaluationReason of the kind . @@ -179,7 +201,7 @@ public static EvaluationReason RuleMatchReason(int ruleIndex, string ruleId) => /// the key of the prerequisite flag /// a reason descriptor public static EvaluationReason PrerequisiteFailedReason(string key) => - new EvaluationReason(EvaluationReasonKind.PrerequisiteFailed, null, null, key, null, false, null); + new EvaluationReason(EvaluationReasonKind.PrerequisiteFailed, null, null, key, null, false, null, false); /// /// Returns an EvaluationReason of the kind . @@ -187,7 +209,7 @@ public static EvaluationReason PrerequisiteFailedReason(string key) => /// /// a reason descriptor public static EvaluationReason ErrorReason(EvaluationErrorKind errorKind) => - new EvaluationReason(EvaluationReasonKind.Error, null, null, null, errorKind, false, null); + new EvaluationReason(EvaluationReasonKind.Error, null, null, null, errorKind, false, null, false); /// /// Returns a copy of this EvaluationReason with a specific value added. @@ -196,7 +218,20 @@ public static EvaluationReason ErrorReason(EvaluationErrorKind errorKind) => /// a reason descriptor public EvaluationReason WithBigSegmentsStatus(BigSegmentsStatus? bigSegmentsStatus) => new EvaluationReason(_kind, _ruleIndex, _ruleId, _prerequisiteKey, _errorKind, - _inExperiment, bigSegmentsStatus); + _inExperiment, bigSegmentsStatus, _overrideAffected); + + /// + /// Returns a copy of this EvaluationReason with a specific value. + /// + /// + /// The copy keeps all other properties of this reason. Flag overrides are currently experimental + /// and subject to change. + /// + /// the new property value + /// a reason descriptor + public EvaluationReason WithOverrideAffected(bool overrideAffected) => + new EvaluationReason(_kind, _ruleIndex, _ruleId, _prerequisiteKey, _errorKind, + _inExperiment, _bigSegmentsStatus, overrideAffected); /// /// Returns a new instance with the property set to the specified @@ -214,7 +249,8 @@ public EvaluationReason WithInExperiment(bool inExperiment) { case EvaluationReasonKind.Fallthrough: case EvaluationReasonKind.RuleMatch: - return new EvaluationReason(_kind, _ruleIndex, _ruleId, _prerequisiteKey, _errorKind, inExperiment, _bigSegmentsStatus); + return new EvaluationReason(_kind, _ruleIndex, _ruleId, _prerequisiteKey, _errorKind, inExperiment, + _bigSegmentsStatus, _overrideAffected); default: return this; } @@ -225,12 +261,13 @@ public override bool Equals(object obj) => obj is EvaluationReason o && _kind == o._kind && _ruleId == o._ruleId && _ruleIndex == o._ruleIndex && _prerequisiteKey == o._prerequisiteKey && _errorKind == o._errorKind && - _inExperiment == o._inExperiment && _bigSegmentsStatus == o._bigSegmentsStatus; + _inExperiment == o._inExperiment && _bigSegmentsStatus == o._bigSegmentsStatus && + _overrideAffected == o._overrideAffected; /// public override int GetHashCode() => new HashCodeBuilder().With(_kind).With(_ruleIndex).With(_ruleId).With(_prerequisiteKey) - .With(_errorKind).With(_inExperiment).With(_bigSegmentsStatus).Value; + .With(_errorKind).With(_inExperiment).With(_bigSegmentsStatus).With(_overrideAffected).Value; /// public override string ToString() diff --git a/pkgs/shared/common/src/Json/LdJsonConverters.cs b/pkgs/shared/common/src/Json/LdJsonConverters.cs index 228e7b554..cca68dd46 100644 --- a/pkgs/shared/common/src/Json/LdJsonConverters.cs +++ b/pkgs/shared/common/src/Json/LdJsonConverters.cs @@ -74,6 +74,7 @@ public static EvaluationReason ReadJsonValue(ref Utf8JsonReader reader) string prerequisiteKey = null; EvaluationErrorKind? errorKind = null; bool inExperiment = false; + bool overrideAffected = false; BigSegmentsStatus? bigSegmentsStatus = null; RequireToken(ref reader, JsonTokenType.StartObject); @@ -99,6 +100,9 @@ public static EvaluationReason ReadJsonValue(ref Utf8JsonReader reader) case "inExperiment": inExperiment = reader.GetBoolean(); break; + case "overrideAffected": + overrideAffected = reader.GetBoolean(); + break; case "bigSegmentsStatus": bigSegmentsStatus = BigSegmentsStatusConverter.FromIdentifier(reader.GetString()); break; @@ -146,6 +150,10 @@ public static EvaluationReason ReadJsonValue(ref Utf8JsonReader reader) { reason = reason.WithBigSegmentsStatus(bigSegmentsStatus); } + if (overrideAffected) + { + reason = reason.WithOverrideAffected(true); + } return reason; } @@ -171,6 +179,10 @@ public static void WriteJsonValue(EvaluationReason value, Utf8JsonWriter writer) { writer.WriteBoolean("inExperiment", true); // omit property if false } + if (value.OverrideAffected) + { + writer.WriteBoolean("overrideAffected", true); // omit property if false + } if (value.BigSegmentsStatus.HasValue) { writer.WritePropertyName("bigSegmentsStatus"); diff --git a/pkgs/shared/common/test/EvaluationDetailTest.cs b/pkgs/shared/common/test/EvaluationDetailTest.cs index beaf8a3a5..70973651c 100644 --- a/pkgs/shared/common/test/EvaluationDetailTest.cs +++ b/pkgs/shared/common/test/EvaluationDetailTest.cs @@ -72,6 +72,25 @@ public void TestReasonSerializationDeserialization() new ReasonTestCase { Reason = EvaluationReason.ErrorReason(EvaluationErrorKind.Exception), JsonString = @"{""kind"":""ERROR"",""errorKind"":""EXCEPTION""}", ExpectedShortString = "ERROR(EXCEPTION)" + }, + new ReasonTestCase { Reason = EvaluationReason.OffReason.WithOverrideAffected(true), + JsonString = @"{""kind"":""OFF"",""overrideAffected"":true}", ExpectedShortString = "OFF" }, + new ReasonTestCase { + Reason = EvaluationReason.RuleMatchReason(1, "id").WithInExperiment(true).WithOverrideAffected(true), + JsonString = @"{""kind"":""RULE_MATCH"",""ruleIndex"":1,""ruleId"":""id"",""inExperiment"":true,""overrideAffected"":true}", + ExpectedShortString = "RULE_MATCH(1,id)" + }, + new ReasonTestCase { + Reason = EvaluationReason.FallthroughReason.WithBigSegmentsStatus(BigSegmentsStatus.Healthy).WithOverrideAffected(true), + JsonString = @"{""kind"":""FALLTHROUGH"",""bigSegmentsStatus"":""HEALTHY"",""overrideAffected"":true}", + ExpectedShortString = "FALLTHROUGH" }, + new ReasonTestCase { Reason = EvaluationReason.PrerequisiteFailedReason("key").WithOverrideAffected(true), + JsonString = @"{""kind"":""PREREQUISITE_FAILED"",""prerequisiteKey"":""key"",""overrideAffected"":true}", + ExpectedShortString = "PREREQUISITE_FAILED(key)" + }, + new ReasonTestCase { Reason = EvaluationReason.ErrorReason(EvaluationErrorKind.MalformedFlag).WithOverrideAffected(true), + JsonString = @"{""kind"":""ERROR"",""errorKind"":""MALFORMED_FLAG"",""overrideAffected"":true}", + ExpectedShortString = "ERROR(MALFORMED_FLAG)" } }) { @@ -81,6 +100,60 @@ public void TestReasonSerializationDeserialization() } } + [Fact] + public void OverrideAffectedIsFalseByDefault() + { + Assert.False(EvaluationReason.OffReason.OverrideAffected); + Assert.False(EvaluationReason.FallthroughReason.OverrideAffected); + Assert.False(EvaluationReason.TargetMatchReason.OverrideAffected); + Assert.False(EvaluationReason.RuleMatchReason(0, "id").OverrideAffected); + Assert.False(EvaluationReason.PrerequisiteFailedReason("key").OverrideAffected); + Assert.False(EvaluationReason.ErrorReason(EvaluationErrorKind.FlagNotFound).OverrideAffected); + } + + [Fact] + public void WithOverrideAffectedKeepsOtherProperties() + { + var reason = EvaluationReason.RuleMatchReason(2, "rule").WithInExperiment(true) + .WithBigSegmentsStatus(BigSegmentsStatus.Stale); + var marked = reason.WithOverrideAffected(true); + Assert.True(marked.OverrideAffected); + Assert.Equal(EvaluationReasonKind.RuleMatch, marked.Kind); + Assert.Equal(2, marked.RuleIndex); + Assert.Equal("rule", marked.RuleId); + Assert.True(marked.InExperiment); + Assert.Equal(BigSegmentsStatus.Stale, marked.BigSegmentsStatus); + + Assert.False(marked.WithOverrideAffected(false).OverrideAffected); + Assert.Equal(reason, marked.WithOverrideAffected(false)); + } + + [Fact] + public void OtherCopyMethodsKeepOverrideAffected() + { + var marked = EvaluationReason.FallthroughReason.WithOverrideAffected(true); + Assert.True(marked.WithInExperiment(true).OverrideAffected); + Assert.True(marked.WithBigSegmentsStatus(BigSegmentsStatus.Healthy).OverrideAffected); + } + + [Fact] + public void OverrideAffectedIsOmittedFromJsonWhenFalse() + { + var json = LdValue.Parse(LdJsonSerialization.SerializeObject(EvaluationReason.OffReason)); + Assert.Equal(LdValue.Null, json.Get("overrideAffected")); + + var explicitFalse = EvaluationReason.OffReason.WithOverrideAffected(true).WithOverrideAffected(false); + json = LdValue.Parse(LdJsonSerialization.SerializeObject(explicitFalse)); + Assert.Equal(LdValue.Null, json.Get("overrideAffected")); + } + + [Fact] + public void OverrideAffectedFalseInJsonIsAccepted() + { + var reason = LdJsonSerialization.DeserializeObject(@"{""kind"":""OFF"",""overrideAffected"":false}"); + Assert.Equal(EvaluationReason.OffReason, reason); + } + [Fact] public void TestBigSegmentsStatusSerializationDeserialization() { @@ -127,7 +200,9 @@ public void TestEqualityAndHashCode() TypeBehavior.CheckEqualsAndHashCode( // each value in this list should be unequal to all the other values and equal to itself () => EvaluationReason.OffReason, + () => EvaluationReason.OffReason.WithOverrideAffected(true), () => EvaluationReason.FallthroughReason, + () => EvaluationReason.FallthroughReason.WithOverrideAffected(true), () => EvaluationReason.FallthroughReason.WithInExperiment(true), () => EvaluationReason.RuleMatchReason(0, "rule1"), () => EvaluationReason.RuleMatchReason(0, "rule1").WithInExperiment(true), @@ -136,6 +211,7 @@ public void TestEqualityAndHashCode() () => EvaluationReason.PrerequisiteFailedReason("a"), () => EvaluationReason.PrerequisiteFailedReason("b"), () => EvaluationReason.ErrorReason(EvaluationErrorKind.FlagNotFound), + () => EvaluationReason.ErrorReason(EvaluationErrorKind.FlagNotFound).WithOverrideAffected(true), () => EvaluationReason.ErrorReason(EvaluationErrorKind.Exception) ); }