diff --git a/.changeset/case-folding-parity.md b/.changeset/case-folding-parity.md
new file mode 100644
index 00000000..a200ec84
--- /dev/null
+++ b/.changeset/case-folding-parity.md
@@ -0,0 +1,6 @@
+---
+"PostHog": patch
+"PostHog.AspNetCore": patch
+---
+
+Match local feature flag string operators using the same ASCII and Unicode lowercase rules as the flags service.
diff --git a/bin/generate-unicode-lowercase-data b/bin/generate-unicode-lowercase-data
new file mode 100755
index 00000000..ec1334ba
--- /dev/null
+++ b/bin/generate-unicode-lowercase-data
@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+"""Generate the Unicode property ranges needed by the Final_Sigma rule."""
+
+from __future__ import annotations
+
+import hashlib
+import urllib.request
+from pathlib import Path
+
+UNICODE_VERSION = "17.0.0"
+FILES = {
+ "DerivedCoreProperties.txt": "24c7fed1195c482faaefd5c1e7eb821c5ee1fb6de07ecdbaa64b56a99da22c08",
+ "SpecialCasing.txt": "efc25faf19de21b92c1194c111c932e03d2a5eaf18194e33f1156e96de4c9588",
+}
+OUTPUT = Path(__file__).resolve().parents[1] / "src/PostHog/Json/UnicodeSpecialCasingData.cs"
+EXPECTED_RANGE_COUNTS = {"Cased": 175, "Case_Ignorable": 518}
+
+
+def download(filename: str) -> str:
+ url = f"https://www.unicode.org/Public/{UNICODE_VERSION}/ucd/{filename}"
+ contents = urllib.request.urlopen(url).read()
+ digest = hashlib.sha256(contents).hexdigest()
+ if digest != FILES[filename]:
+ raise RuntimeError(f"Unexpected SHA-256 for {url}: {digest}")
+ return contents.decode("utf-8")
+
+
+def property_ranges(contents: str, property_name: str) -> list[tuple[int, int]]:
+ ranges: list[tuple[int, int]] = []
+ for line in contents.splitlines():
+ data = line.split("#", 1)[0].strip()
+ if not data:
+ continue
+ fields = [field.strip() for field in data.split(";")]
+ if len(fields) < 2 or fields[1] != property_name:
+ continue
+ bounds = fields[0].split("..")
+ start = int(bounds[0], 16)
+ end = int(bounds[-1], 16)
+ ranges.append((start, end))
+ return ranges
+
+
+def validate_ranges(property_name: str, ranges: list[tuple[int, int]]) -> None:
+ if len(ranges) != EXPECTED_RANGE_COUNTS[property_name]:
+ raise RuntimeError(f"Unexpected {property_name} range count: {len(ranges)}")
+ for index, (start, end) in enumerate(ranges):
+ if start > end or (index > 0 and start <= ranges[index - 1][1]):
+ raise RuntimeError(f"Invalid {property_name} range at index {index}: {(start, end)}")
+
+
+def format_ranges(ranges: list[tuple[int, int]]) -> str:
+ values = [value for bounds in ranges for value in bounds]
+ lines = []
+ for index in range(0, len(values), 8):
+ lines.append(" " + ", ".join(f"0x{value:04X}" for value in values[index:index + 8]) + ",")
+ return "\n".join(lines)
+
+
+def main() -> None:
+ derived = download("DerivedCoreProperties.txt")
+ special = download("SpecialCasing.txt")
+ if "0130; 0069 0307; 0130; 0130;" not in special:
+ raise RuntimeError("Unicode dotted-I lowercase mapping was not found")
+ if "03A3; 03C2; 03A3; 03A3; Final_Sigma;" not in special:
+ raise RuntimeError("Unicode Final_Sigma lowercase mapping was not found")
+
+ cased = property_ranges(derived, "Cased")
+ case_ignorable = property_ranges(derived, "Case_Ignorable")
+ validate_ranges("Cased", cased)
+ validate_ranges("Case_Ignorable", case_ignorable)
+ source = f'''//
+// Unicode {UNICODE_VERSION} data generated by bin/generate-unicode-lowercase-data.
+// © 2025 Unicode, Inc.
+// For terms of use and license, see https://www.unicode.org/terms_of_use.html
+// Unicode Data Files and Software License: https://www.unicode.org/license.txt
+// Sources:
+// https://www.unicode.org/Public/{UNICODE_VERSION}/ucd/DerivedCoreProperties.txt
+// SHA-256: {FILES["DerivedCoreProperties.txt"]}
+// https://www.unicode.org/Public/{UNICODE_VERSION}/ucd/SpecialCasing.txt
+// SHA-256: {FILES["SpecialCasing.txt"]}
+
+namespace PostHog.Json;
+
+internal static class UnicodeSpecialCasingData
+{{
+ static readonly int[] CasedRanges =
+ [
+{format_ranges(cased)}
+ ];
+
+ static readonly int[] CaseIgnorableRanges =
+ [
+{format_ranges(case_ignorable)}
+ ];
+
+ internal static bool IsCased(int codePoint) => Contains(CasedRanges, codePoint);
+
+ internal static bool IsCaseIgnorable(int codePoint) => Contains(CaseIgnorableRanges, codePoint);
+
+ static bool Contains(int[] ranges, int codePoint)
+ {{
+ var lower = 0;
+ var upper = ranges.Length / 2 - 1;
+ while (lower <= upper)
+ {{
+ var middle = lower + (upper - lower) / 2;
+ var start = ranges[middle * 2];
+ var end = ranges[middle * 2 + 1];
+ if (codePoint < start)
+ {{
+ upper = middle - 1;
+ }}
+ else if (codePoint > end)
+ {{
+ lower = middle + 1;
+ }}
+ else
+ {{
+ return true;
+ }}
+ }}
+ return false;
+ }}
+}}
+'''
+ OUTPUT.write_text(source)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/PostHog/Features/LocalEvaluator.cs b/src/PostHog/Features/LocalEvaluator.cs
index 88cd5b79..446c359d 100644
--- a/src/PostHog/Features/LocalEvaluator.cs
+++ b/src/PostHog/Features/LocalEvaluator.cs
@@ -658,10 +658,21 @@ bool MatchProperty(PropertyFilter propertyFilter, string distinctId, Dictionary<
throw new InconclusiveMatchException("The filter property value is null");
}
- if (overrideValue is null && propertyFilter.Operator != ComparisonOperator.IsNot)
- {
- // If the value is null, just fail the feature flag comparison. This doesn't throw an
- // InconclusiveMatchException because the property value was provided.
+ if (overrideValue is null
+ && propertyFilter.Operator is not (
+ ComparisonOperator.Exact
+ or ComparisonOperator.IsNot
+ or ComparisonOperator.ContainsIgnoreCase
+ or ComparisonOperator.DoesNotContainIgnoreCase
+ or ComparisonOperator.StartsWith
+ or ComparisonOperator.NotStartsWith
+ or ComparisonOperator.EndsWith
+ or ComparisonOperator.NotEndsWith
+ or ComparisonOperator.Regex
+ or ComparisonOperator.NotRegex))
+ {
+ // The backend stringifies null for string operators and exact matching. Other value operators keep the
+ // existing null non-match behavior.
return false;
}
@@ -673,12 +684,12 @@ bool MatchProperty(PropertyFilter propertyFilter, string distinctId, Dictionary<
ComparisonOperator.GreaterThanOrEquals => value <= overrideValue,
ComparisonOperator.LessThan => value > overrideValue,
ComparisonOperator.LessThanOrEquals => value >= overrideValue,
- ComparisonOperator.ContainsIgnoreCase => value.IsContainedBy(overrideValue, StringComparison.OrdinalIgnoreCase),
- ComparisonOperator.DoesNotContainIgnoreCase => !value.IsContainedBy(overrideValue, StringComparison.OrdinalIgnoreCase),
- ComparisonOperator.StartsWith => value.IsPrefixOf(overrideValue, StringComparison.OrdinalIgnoreCase),
- ComparisonOperator.NotStartsWith => !value.IsPrefixOf(overrideValue, StringComparison.OrdinalIgnoreCase),
- ComparisonOperator.EndsWith => value.IsSuffixOf(overrideValue, StringComparison.OrdinalIgnoreCase),
- ComparisonOperator.NotEndsWith => !value.IsSuffixOf(overrideValue, StringComparison.OrdinalIgnoreCase),
+ ComparisonOperator.ContainsIgnoreCase => value.IsContainedByAsciiIgnoreCase(overrideValue),
+ ComparisonOperator.DoesNotContainIgnoreCase => !value.IsContainedByAsciiIgnoreCase(overrideValue),
+ ComparisonOperator.StartsWith => value.IsPrefixOfAsciiIgnoreCase(overrideValue),
+ ComparisonOperator.NotStartsWith => !value.IsPrefixOfAsciiIgnoreCase(overrideValue),
+ ComparisonOperator.EndsWith => value.IsSuffixOfAsciiIgnoreCase(overrideValue),
+ ComparisonOperator.NotEndsWith => !value.IsSuffixOfAsciiIgnoreCase(overrideValue),
ComparisonOperator.Regex => value.IsRegexMatch(overrideValue),
ComparisonOperator.NotRegex => !value.IsRegexMatch(overrideValue),
ComparisonOperator.IsDateBefore => value.IsDateBefore(overrideValue, _timeProvider.GetUtcNow()),
diff --git a/src/PostHog/Json/PropertyFilterValue.cs b/src/PostHog/Json/PropertyFilterValue.cs
index 62a6adb8..6f2db5f0 100644
--- a/src/PostHog/Json/PropertyFilterValue.cs
+++ b/src/PostHog/Json/PropertyFilterValue.cs
@@ -1,5 +1,8 @@
+using System.Collections;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
+using System.Runtime.CompilerServices;
+using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
@@ -21,6 +24,7 @@ namespace PostHog.Json;
public class PropertyFilterValue
{
readonly IReadOnlyList? _numericListValues;
+ readonly bool? _booleanListValue;
///
/// If this value is a string, this property will be set.
@@ -50,8 +54,12 @@ public class PropertyFilterValue
jsonElement.ValueKind switch
{
JsonValueKind.String => jsonElement.GetString() is { } stringValue ? new PropertyFilterValue(stringValue) : null,
- JsonValueKind.Array when TryParseStringArray(jsonElement, out var stringArrayValue, out var numericListValues)
- => new PropertyFilterValue(stringArrayValue, numericListValues),
+ JsonValueKind.Array when TryParseStringArray(
+ jsonElement,
+ out var stringArrayValue,
+ out var numericListValues,
+ out var booleanListValue)
+ => new PropertyFilterValue(stringArrayValue, numericListValues, booleanListValue),
JsonValueKind.Number => new PropertyFilterValue(jsonElement.GetInt64()),
JsonValueKind.True => new PropertyFilterValue(true),
JsonValueKind.False => new PropertyFilterValue(false),
@@ -66,13 +74,18 @@ JsonValueKind.Array when TryParseStringArray(jsonElement, out var stringArrayVal
/// The list of string values to match against.
public PropertyFilterValue(IReadOnlyList listOfStrings)
{
- ListOfStrings = listOfStrings;
+ ListOfStrings = NotNull(listOfStrings);
+ _booleanListValue = TryGetBooleanListValue(ListOfStrings);
}
- PropertyFilterValue(IReadOnlyList listOfStrings, IReadOnlyList? numericListValues)
+ PropertyFilterValue(
+ IReadOnlyList listOfStrings,
+ IReadOnlyList? numericListValues,
+ bool? booleanListValue)
{
ListOfStrings = listOfStrings;
_numericListValues = numericListValues;
+ _booleanListValue = booleanListValue;
}
///
@@ -115,11 +128,6 @@ public PropertyFilterValue(bool booleanValue)
/// trueIf the current value is a valid regex and it matches the other value.
public bool IsRegexMatch(object? input)
{
- if (input is null)
- {
- return false;
- }
-
if (StringValue is null || !RegexHelpers.TryValidateRegex(StringValue, out var regex, RegexOptions.None))
{
return false;
@@ -161,6 +169,21 @@ public bool IsSuffixOf(object? other, StringComparison stringComparison) =>
&& StringValue is not null
&& comparandString.EndsWith(StringValue, stringComparison);
+ internal bool IsContainedByAsciiIgnoreCase(object? other) =>
+ ToInvariantString(other) is { } comparandString
+ && StringValue is not null
+ && ToAsciiLowercase(comparandString).Contains(ToAsciiLowercase(StringValue), StringComparison.Ordinal);
+
+ internal bool IsPrefixOfAsciiIgnoreCase(object? other) =>
+ ToInvariantString(other) is { } comparandString
+ && StringValue is not null
+ && ToAsciiLowercase(comparandString).StartsWith(ToAsciiLowercase(StringValue), StringComparison.Ordinal);
+
+ internal bool IsSuffixOfAsciiIgnoreCase(object? other) =>
+ ToInvariantString(other) is { } comparandString
+ && StringValue is not null
+ && ToAsciiLowercase(comparandString).EndsWith(ToAsciiLowercase(StringValue), StringComparison.Ordinal);
+
///
/// Determines whether the specified is an "exact" match for this instance.
/// If this instance is an array, then it's checking to see if the value is in the array.
@@ -169,20 +192,40 @@ public bool IsSuffixOf(object? other, StringComparison stringComparison) =>
/// true if the override value is an "exact" match for this value.
public bool IsExactMatch(object? overrideValue)
{
+ if (TryGetBooleanValue(out var booleanValue))
+ {
+ return booleanValue == IsTruthyPropertyValue(overrideValue);
+ }
+
return this switch
{
{ ListOfStrings: { } listOfStrings } => IsExactListMatch(listOfStrings, _numericListValues, overrideValue),
- { StringValue: { } stringValue } => stringValue.Equals(ToInvariantString(overrideValue), StringComparison.OrdinalIgnoreCase),
- { BooleanValue: { } booleanValue } => overrideValue switch
- {
- bool boolOverride => booleanValue == boolOverride,
- string stringOverride => booleanValue.ToString().Equals(stringOverride, StringComparison.OrdinalIgnoreCase),
- _ => false
- },
+ { StringValue: { } stringValue } => UnicodeLowercaseEquals(stringValue, ToInvariantString(overrideValue)),
_ => false
};
}
+ bool TryGetBooleanValue(out bool value)
+ {
+ if (BooleanValue is { } booleanValue)
+ {
+ value = booleanValue;
+ return true;
+ }
+ if (StringValue is { } stringValue && TryParseBoolean(stringValue, out value))
+ {
+ return true;
+ }
+ if (_booleanListValue is { } booleanListValue)
+ {
+ value = booleanListValue;
+ return true;
+ }
+
+ value = false;
+ return false;
+ }
+
static bool IsExactListMatch(
IReadOnlyList values,
IReadOnlyList? numericValues,
@@ -194,7 +237,7 @@ static bool IsExactListMatch(
}
var stringValue = ToInvariantString(overrideValue);
- if (stringValue is not null && values.Contains(stringValue, StringComparer.OrdinalIgnoreCase))
+ if (stringValue is not null && values.Any(value => UnicodeLowercaseEquals(value, stringValue)))
{
return true;
}
@@ -221,10 +264,509 @@ or TypeCode.SByte or TypeCode.UInt16 or TypeCode.UInt32 or TypeCode.UInt64
};
}
- // Override values must stringify with the invariant culture ("3.14", never "3,14") to match how the
- // PostHog flags service stringifies values. Null stays null so null overrides never match string filters.
+ // Override values use the same compact JSON representation as serde_json::Value::to_string in the flags service.
+ // Strings remain unquoted because the service returns their contents directly.
static string? ToInvariantString(object? value) =>
- value is null ? null : Convert.ToString(value, CultureInfo.InvariantCulture);
+ ToInvariantString(value, new HashSet