From b5deba0e6efda6c31ef52c15bedd949d431e56cd Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:01:23 -0700 Subject: [PATCH] feat: Add the overrideAffected indicator to EvaluationReason The OVERRIDE specification marks an evaluation as override-affected when any definition it read came from the SDK's override store: the flag itself, a prerequisite at any depth, or a segment consulted during matching. The evaluation reason carries that marking as a boolean indicator named overrideAffected, alongside inExperiment, so that callers of the variation detail methods can tell such evaluations apart. EvaluationReason gains isOverrideAffected() and withOverrideAffected(), which returns the same instance when the value is unchanged so that the shared static instances stay shared. The indicator takes part in equality and hashing. The JSON representation writes overrideAffected only when it is true, and parsing accepts the property. Flag overrides are currently experimental and subject to change. --- .../launchdarkly/sdk/EvaluationReason.java | 55 ++++++++++++--- .../sdk/EvaluationReasonTypeAdapter.java | 14 +++- .../sdk/EvaluationReasonTest.java | 69 ++++++++++++++++++- ...EvaluationReasonJsonSerializationTest.java | 20 ++++++ 4 files changed, 146 insertions(+), 12 deletions(-) diff --git a/lib/shared/common/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/lib/shared/common/src/main/java/com/launchdarkly/sdk/EvaluationReason.java index 7c5da7f8..93c95acd 100644 --- a/lib/shared/common/src/main/java/com/launchdarkly/sdk/EvaluationReason.java +++ b/lib/shared/common/src/main/java/com/launchdarkly/sdk/EvaluationReason.java @@ -142,9 +142,10 @@ public static enum BigSegmentsStatus { private final ErrorKind errorKind; private final Exception exception; private final BigSegmentsStatus bigSegmentsStatus; + private final boolean overrideAffected; private EvaluationReason(Kind kind, int ruleIndex, String ruleId, String prerequisiteKey, boolean inExperiment, - ErrorKind errorKind, Exception exception, BigSegmentsStatus bigSegmentsStatus) { + ErrorKind errorKind, Exception exception, BigSegmentsStatus bigSegmentsStatus, boolean overrideAffected) { this.kind = kind; this.ruleIndex = ruleIndex; this.ruleId = ruleId; @@ -153,18 +154,19 @@ private EvaluationReason(Kind kind, int ruleIndex, String ruleId, String prerequ this.errorKind = errorKind; this.exception = exception; this.bigSegmentsStatus = bigSegmentsStatus; + this.overrideAffected = overrideAffected; } private EvaluationReason(Kind kind) { - this(kind, -1, null, null, NOT_IN_EXPERIMENT, null, null, null); + this(kind, -1, null, null, NOT_IN_EXPERIMENT, null, null, null, false); } private EvaluationReason(Kind kind, boolean inExperiment) { - this(kind, -1, null, null, inExperiment, null, null, null); + this(kind, -1, null, null, inExperiment, null, null, null, false); } private EvaluationReason(ErrorKind errorKind, Exception exception) { - this(Kind.ERROR, -1, null, null, NOT_IN_EXPERIMENT, errorKind, exception, null); + this(Kind.ERROR, -1, null, null, NOT_IN_EXPERIMENT, errorKind, exception, null, false); } /** @@ -267,7 +269,41 @@ public BigSegmentsStatus getBigSegmentsStatus() { */ public EvaluationReason withBigSegmentsStatus(BigSegmentsStatus bigSegmentsStatus) { return new EvaluationReason(kind, ruleIndex, ruleId, prerequisiteKey, inExperiment, errorKind, - exception, bigSegmentsStatus); + exception, bigSegmentsStatus, overrideAffected); + } + + /** + * Whether an override affected this evaluation, directly or transitively. Returns true if the + * evaluated flag came from the SDK's override store. Also returns true if a prerequisite flag at + * any depth, or a segment read during the evaluation, came from that store. Otherwise it returns + * false. + *

+ * In the JSON representation, the {@code overrideAffected} property appears only when this + * value is true. + *

+ * Flag overrides are currently experimental and subject to change. + * + * @return whether an override affected the evaluation + */ + public boolean isOverrideAffected() { + return overrideAffected; + } + + /** + * Returns a copy of this {@link EvaluationReason} with a specific value for + * {@link #isOverrideAffected()}. The copy keeps all other properties of the original reason. + *

+ * Flag overrides are currently experimental and subject to change. + * + * @param overrideAffected the new property value + * @return a reason object with the given value, which is this same instance if the value is unchanged + */ + public EvaluationReason withOverrideAffected(boolean overrideAffected) { + if (this.overrideAffected == overrideAffected) { + return this; + } + return new EvaluationReason(kind, ruleIndex, ruleId, prerequisiteKey, inExperiment, errorKind, + exception, bigSegmentsStatus, overrideAffected); } /** @@ -305,7 +341,8 @@ public boolean equals(Object other) { inExperiment == o.inExperiment && Objects.equals(errorKind, o.errorKind) && Objects.equals(exception, o.exception) && - Objects.equals(bigSegmentsStatus, o.bigSegmentsStatus); + Objects.equals(bigSegmentsStatus, o.bigSegmentsStatus) && + overrideAffected == o.overrideAffected; } return false; } @@ -313,7 +350,7 @@ public boolean equals(Object other) { @Override public int hashCode() { return Objects.hash(kind, ruleIndex, ruleId, prerequisiteKey, inExperiment, errorKind, - exception, bigSegmentsStatus); + exception, bigSegmentsStatus, overrideAffected); } /** @@ -377,7 +414,7 @@ public static EvaluationReason ruleMatch(int ruleIndex, String ruleId) { * @return a reason object */ public static EvaluationReason ruleMatch(int ruleIndex, String ruleId, boolean inExperiment) { - return new EvaluationReason(Kind.RULE_MATCH, ruleIndex, ruleId, null, inExperiment, null, null, null); + return new EvaluationReason(Kind.RULE_MATCH, ruleIndex, ruleId, null, inExperiment, null, null, null, false); } /** @@ -387,7 +424,7 @@ public static EvaluationReason ruleMatch(int ruleIndex, String ruleId, boolean i * @return a reason object */ public static EvaluationReason prerequisiteFailed(String prerequisiteKey) { - return new EvaluationReason(Kind.PREREQUISITE_FAILED, -1, null, prerequisiteKey, NOT_IN_EXPERIMENT, null, null, null); + return new EvaluationReason(Kind.PREREQUISITE_FAILED, -1, null, prerequisiteKey, NOT_IN_EXPERIMENT, null, null, null, false); } /** diff --git a/lib/shared/common/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java b/lib/shared/common/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java index 98ec00a2..0bfa1ec4 100644 --- a/lib/shared/common/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java +++ b/lib/shared/common/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java @@ -25,6 +25,7 @@ static EvaluationReason parse(JsonReader reader) throws IOException { boolean inExperiment = false; EvaluationReason.ErrorKind errorKind = null; EvaluationReason.BigSegmentsStatus bigSegmentsStatus = null; + boolean overrideAffected = false; reader.beginObject(); while (reader.peek() != JsonToken.END_OBJECT) { @@ -51,6 +52,9 @@ static EvaluationReason parse(JsonReader reader) throws IOException { case "bigSegmentsStatus": bigSegmentsStatus = readEnum(EvaluationReason.BigSegmentsStatus.class, reader); break; + case "overrideAffected": + overrideAffected = reader.nextBoolean(); + break; default: reader.skipValue(); // ignore any unexpected property } @@ -85,9 +89,9 @@ static EvaluationReason parse(JsonReader reader) throws IOException { return null; } if (bigSegmentsStatus != null) { - return reason.withBigSegmentsStatus(bigSegmentsStatus); + reason = reason.withBigSegmentsStatus(bigSegmentsStatus); } - return reason; + return reason.withOverrideAffected(overrideAffected); } @Override @@ -134,6 +138,12 @@ public void write(JsonWriter writer, EvaluationReason reason) throws IOException writer.name("bigSegmentsStatus"); writer.value(reason.getBigSegmentsStatus().name()); } + + // The override marker is written only when it is true, like inExperiment. + if (reason.isOverrideAffected()) { + writer.name("overrideAffected"); + writer.value(true); + } writer.endObject(); } diff --git a/lib/shared/common/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java b/lib/shared/common/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java index d7bd0a18..19c546b7 100644 --- a/lib/shared/common/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java +++ b/lib/shared/common/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java @@ -12,8 +12,11 @@ import static com.launchdarkly.sdk.EvaluationReason.Kind.RULE_MATCH; import static com.launchdarkly.sdk.EvaluationReason.Kind.TARGET_MATCH; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; import static java.util.Arrays.asList; import org.junit.Test; @@ -84,6 +87,67 @@ public void bigSegmentsStatus() { assertNull(reason.getBigSegmentsStatus()); } + @Test + public void overrideAffectedIsFalseByDefault() { + assertFalse(EvaluationReason.off().isOverrideAffected()); + assertFalse(EvaluationReason.fallthrough().isOverrideAffected()); + assertFalse(EvaluationReason.fallthrough(true).isOverrideAffected()); + assertFalse(EvaluationReason.targetMatch().isOverrideAffected()); + assertFalse(EvaluationReason.ruleMatch(1, "id").isOverrideAffected()); + assertFalse(EvaluationReason.prerequisiteFailed("key").isOverrideAffected()); + assertFalse(EvaluationReason.error(FLAG_NOT_FOUND).isOverrideAffected()); + assertFalse(EvaluationReason.exception(new Exception("sorry")).isOverrideAffected()); + assertFalse(EvaluationReason.fallthrough().withBigSegmentsStatus(HEALTHY).isOverrideAffected()); + } + + @Test + public void withOverrideAffectedKeepsOtherProperties() { + EvaluationReason reason = EvaluationReason.ruleMatch(2, "id", true).withBigSegmentsStatus(STALE); + EvaluationReason marked = reason.withOverrideAffected(true); + + assertTrue(marked.isOverrideAffected()); + assertFalse(reason.isOverrideAffected()); + assertEquals(RULE_MATCH, marked.getKind()); + assertEquals(2, marked.getRuleIndex()); + assertEquals("id", marked.getRuleId()); + assertTrue(marked.isInExperiment()); + assertEquals(STALE, marked.getBigSegmentsStatus()); + + Exception e = new Exception("sorry"); + EvaluationReason markedError = EvaluationReason.exception(e).withOverrideAffected(true); + assertEquals(ERROR, markedError.getKind()); + assertEquals(EvaluationReason.ErrorKind.EXCEPTION, markedError.getErrorKind()); + assertEquals(e, markedError.getException()); + assertTrue(markedError.isOverrideAffected()); + + // The marking survives a later change of another property. + assertTrue(marked.withBigSegmentsStatus(HEALTHY).isOverrideAffected()); + } + + @Test + public void withOverrideAffectedReturnsSameInstanceWhenUnchanged() { + EvaluationReason reason = EvaluationReason.off(); + assertSame(reason, reason.withOverrideAffected(false)); + EvaluationReason marked = reason.withOverrideAffected(true); + assertSame(marked, marked.withOverrideAffected(true)); + assertFalse(marked.withOverrideAffected(false).isOverrideAffected()); + } + + @Test + public void overrideAffectedParticipatesInEquality() { + EvaluationReason plain = EvaluationReason.off(); + EvaluationReason marked = plain.withOverrideAffected(true); + assertNotEquals(plain, marked); + assertEquals(marked, plain.withOverrideAffected(true)); + assertEquals(marked.hashCode(), plain.withOverrideAffected(true).hashCode()); + } + + @Test + public void overrideAffectedDoesNotChangeStringRepresentation() { + assertEquals("OFF", EvaluationReason.off().withOverrideAffected(true).toString()); + assertEquals("RULE_MATCH(1,id)", EvaluationReason.ruleMatch(1, "id").withOverrideAffected(true).toString()); + } + @Test public void simpleStringRepresentations() { assertEquals("OFF", EvaluationReason.off().toString()); @@ -129,7 +193,10 @@ public void equalInstancesAreEqual() { asList(EvaluationReason.ruleMatch(2, "id1"), EvaluationReason.ruleMatch(2, "id1")), asList(EvaluationReason.prerequisiteFailed("a"), EvaluationReason.prerequisiteFailed("a")), asList(EvaluationReason.error(CLIENT_NOT_READY), EvaluationReason.error(CLIENT_NOT_READY)), - asList(EvaluationReason.error(WRONG_TYPE), EvaluationReason.error(WRONG_TYPE)) + asList(EvaluationReason.error(WRONG_TYPE), EvaluationReason.error(WRONG_TYPE)), + asList(EvaluationReason.off().withOverrideAffected(true), EvaluationReason.off().withOverrideAffected(true)), + asList(EvaluationReason.error(WRONG_TYPE).withOverrideAffected(true), + EvaluationReason.error(WRONG_TYPE).withOverrideAffected(true)) ); TestHelpers.doEqualityTests(testValues); } diff --git a/lib/shared/common/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java b/lib/shared/common/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java index 4512f0c3..d94d02e3 100644 --- a/lib/shared/common/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java +++ b/lib/shared/common/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java @@ -59,6 +59,26 @@ public void reasonJsonSerializations() throws Exception { verifyDeserializeInvalidJson(EvaluationReason.class, "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":3}"); } + @Test + public void overrideAffectedIsSerializedOnlyWhenTrue() throws Exception { + verifySerializeAndDeserialize(EvaluationReason.off().withOverrideAffected(true), + "{\"kind\":\"OFF\",\"overrideAffected\":true}"); + verifySerializeAndDeserialize(EvaluationReason.fallthrough(true).withOverrideAffected(true), + "{\"kind\":\"FALLTHROUGH\",\"inExperiment\":true,\"overrideAffected\":true}"); + verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, "id").withOverrideAffected(true), + "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\",\"overrideAffected\":true}"); + verifySerializeAndDeserialize(EvaluationReason.prerequisiteFailed("key").withOverrideAffected(true), + "{\"kind\":\"PREREQUISITE_FAILED\",\"prerequisiteKey\":\"key\",\"overrideAffected\":true}"); + verifySerializeAndDeserialize(EvaluationReason.error(EvaluationReason.ErrorKind.MALFORMED_FLAG).withOverrideAffected(true), + "{\"kind\":\"ERROR\",\"errorKind\":\"MALFORMED_FLAG\",\"overrideAffected\":true}"); + verifySerializeAndDeserialize(EvaluationReason.fallthrough().withBigSegmentsStatus(HEALTHY).withOverrideAffected(true), + "{\"kind\":\"FALLTHROUGH\",\"bigSegmentsStatus\":\"HEALTHY\",\"overrideAffected\":true}"); + + // A false value is never written, and an explicit false parses the same as an absent property. + verifySerialize(EvaluationReason.off().withOverrideAffected(false), "{\"kind\":\"OFF\"}"); + verifyDeserialize(EvaluationReason.off(), "{\"kind\":\"OFF\",\"overrideAffected\":false}"); + } + @Test public void errorSerializationWithException() throws Exception { // We do *not* want the JSON representation to include the exception, because that is used in events, and