Skip to content

fix(flags): match local string filters like the flags service - #306

Merged
marandaneto merged 3 commits into
mainfrom
fix/issue-78019-case-folding
Aug 31, 2026
Merged

fix(flags): match local string filters like the flags service#306
marandaneto merged 3 commits into
mainfrom
fix/issue-78019-case-folding

Conversation

@marandaneto

@marandaneto marandaneto commented Aug 27, 2026

Copy link
Copy Markdown
Member

💡 Motivation and Context

Local feature flag evaluation must return the same result as the currently released Rust flags service for the same property values. The previous implementation used .NET's general case-insensitive comparison and host ToString() behavior, which diverged from /flags. This fixes PostHog/posthog#78019.

This PR intentionally follows the current released backend, not the alternative semantics discussed in draft backend PR PostHog/posthog#90694.

The evaluator now mirrors the backend by:

  • applying the boolean gate before ordinary list membership, including recursively boolean-like arrays, aggregate truthiness, vacuous truth for [], and complete is_not complementation;
  • stringifying representable JSON values like serde_json, including compact arrays, UTF-8-key-sorted objects, null, negative zero, integral-float preservation, and the backend's exponent cutovers;
  • using full Unicode lowercase behavior for exact/is_not, including dotted-I expansion and contextual Final_Sigma handling for scalar and list filters;
  • retaining ASCII-only lowercase behavior for contains, starts-with, ends-with, and their negations.

The contextual sigma helper uses checked-in generated Unicode 17.0.0 Cased and Case_Ignorable ranges. The generator pins the official UCD source SHA-256 hashes, validates range counts/order, carries Unicode provenance/license links, and reproduces the checked-in source byte-for-byte. Cyclic, excessively deep, or otherwise non-JSON CLR collections fail matching safely rather than risking recursion crashes or partial JSON output.

A patch changeset covers PostHog and PostHog.AspNetCore.

💚 How did you test it?

Red-green regression tests cover boolean scalar/list precedence, empty and nested arrays, is_not, canonical JSON objects/arrays/null/floats, final and medial sigma contexts, dotted I, scalar/list filters, and unsafe CLR collection graphs.

  • bin/generate-unicode-lowercase-data plus byte-for-byte comparison with the checked-in generated file
  • SHA-256 verification of Unicode 17.0.0 DerivedCoreProperties.txt and SpecialCasing.txt
  • 80-value finite f64 differential stringification check against serde_json
  • bin/fmt --check
  • dotnet build --configuration Release --no-restore --nologo (0 warnings, 0 errors)
  • dotnet test tests/UnitTests/UnitTests.csproj --framework net8.0 --configuration Release --no-build --nologo (1,137 passed, 2 skipped)
  • dotnet test tests/UnitTests/UnitTests.csproj --framework netcoreapp3.1 --configuration Release --no-build --nologo (1,129 passed, 2 skipped)
  • Bundled autoreview against the dirty local change bundle (clean after addressing collection-safety findings)

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

A human directed the cross-SDK parity work for PostHog/posthog#78019. Pi reproduced the reported differences against the current Rust backend, implemented the .NET parity fixes test-first, and ran the bundled isolated autoreview. The generated Unicode data-table approach was explicitly approved for exact Final_Sigma context handling without adding a runtime dependency.

@marandaneto marandaneto self-assigned this Aug 27, 2026
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

posthog-dotnet Compliance Report

Date: 2026-08-31 06:50:13 UTC
Duration: 4467ms

✅ All Tests Passed!

17/17 tests passed


Feature_Flags Tests

17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 292ms
Request Payload.Flags Request Uses V2 Query Param 131ms
Request Payload.Flags Request Hits Flags Path Not Decide 112ms
Request Payload.Flags Request Omits Authorization Header 110ms
Request Payload.Token In Flags Body Matches Init 112ms
Request Payload.Groups Round Trip 114ms
Request Payload.Groups Default To Empty Object 110ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 110ms
Request Payload.Disable Geoip Omitted Defaults To False 111ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 110ms
Request Lifecycle.No Flags Request On Init Alone 4ms
Request Lifecycle.No Flags Request On Normal Capture 118ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 215ms
Request Lifecycle.Mock Response Value Is Returned To Caller 114ms
Retry Behavior.Retries Flags On 502 1118ms
Retry Behavior.Retries Flags On 504 1113ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 213ms

@marandaneto
marandaneto requested a review from a team August 27, 2026 05:46
@marandaneto
marandaneto marked this pull request as ready for review August 27, 2026 05:51
@marandaneto
marandaneto requested a review from a team as a code owner August 27, 2026 05:51
@greptile-apps

greptile-apps Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "fix(flags): handle expanding Unicode low..." | Re-trigger Greptile

@arnohillen

Copy link
Copy Markdown

UnicodeLowercase at src/PostHog/Json/PropertyFilterValue.cs:293 ends in ToLowerInvariant(), which has no Final_Sigma mapping, so exact with filter ΠΑΡΑΓΓΕΛΙΕΣ against property παραγγελιες returns false here (folds to ...U+03C3) where the service's to_lowercase in rust/feature-flags/src/properties/property_matching.rs:230 returns true (folds to ...U+03C2), a regression from main's OrdinalIgnoreCase which returned true; this also hits is_not and the is_in list path via IsExactListMatch, and the added ("ς", "Σ", false) case stays correct under a Final_Sigma-aware fold.

@marandaneto

Copy link
Copy Markdown
Member Author

@arnohillen Addressed in 1e4cf21. You were right that the previous helper did not reproduce Rust’s contextual Final_Sigma result.

The fix now applies the Unicode 17.0.0 Final_Sigma condition using the official Cased and Case_Ignorable derived-property ranges before invariant lowercase, while retaining the İ -> i + U+0307 expansion. The checked-in generated table is reproducible from pinned UCD files (SHA-256 24c7fed…c8 and efc25faf…88) and includes Unicode provenance/license links.

Regression coverage now exercises ΠΑΡΑΓΓΕΛΙΕΣ, ΟΔΟΣ, final versus medial sigma (including case-ignorable context), dotted I, scalar and list filters, and both exact and is_not. The final Release suites pass on both targets: net8.0 1,137 passed / 2 skipped; netcoreapp3.1 1,129 passed / 2 skipped.

@marandaneto

Copy link
Copy Markdown
Member Author

A note on why this PR includes bin/generate-unicode-lowercase-data and checked-in UnicodeSpecialCasingData.cs instead of using a built-in .NET comparison:

The flags service performs Rust full Unicode lowercase followed by ordinal equality. No .NET built-in reproduces that exact operation:

  • "ΟΣ".ToLowerInvariant() produces "οσ", while Rust/backend lowercase produces "ος" because it applies the contextual Final_Sigma rule.
  • "İ".ToLowerInvariant() remains "İ", while Rust/backend lowercase expands it to "i\u0307".
  • StringComparison.OrdinalIgnoreCase is not equivalent: it considers "ΟΣ" equal to both "ος" and "οσ", while the backend lowercase-then-ordinal behavior matches only "ος".
  • TextInfo.ToLower, Greek culture casing, and Rune.ToLowerInvariant do not provide the required contextual and expanding full-lowercase mapping either.

The generator is maintenance-only and is not invoked by SDK consumers at runtime. It produces a checked-in, deterministic table containing only the Unicode Cased and Case_Ignorable ranges required to evaluate Final_Sigma; dotted-I expansion remains a direct mapping. This keeps behavior stable across netstandard2.0, netstandard2.1, and net8.0 without adding a considerably heavier full Unicode dependency such as ICU4N.

@marandaneto
marandaneto merged commit 5e2e606 into main Aug 31, 2026
21 checks passed
@marandaneto
marandaneto deleted the fix/issue-78019-case-folding branch August 31, 2026 07:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Case folding and numeric stringification are inconsistent between SDK local evaluation and the flags service

3 participants