You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
docs(flags): define local property matching semantics - #51
Local feature flag evaluators can disagree with the released /flags service because the existing specification does not fully define boolean coercion, canonical value stringification, or Unicode lowercase. This is part of the fix for PostHog/posthog#78019.
The released matcher applies a surprising boolean-like gate before ordinary array membership. False-like filters match arbitrary falsey properties, and an empty filter array is truthy because Rust's all() is vacuously true.
PostHog/posthog#90694 is a separate draft that proposes replacing that behavior. SDKs must continue matching the released backend until that proposal is accepted and merged.
Changes
Define the released boolean gate, aggregate boolean arrays, and vacuously truthy empty arrays for exact and is_not.
Preserve ANY/NONE membership only for arrays containing a non-boolean-like value.
Pin canonical serde_json output for compact composites, sorted object keys, negative zero, and measured exponent cutovers.
Define Rust-compatible full Unicode lowercase with contextual final sigma and dotted-I expansion.
Keep ASCII-only lowercase matching for contains, prefix, and suffix operators.
Preserve inconclusive fallback for missing properties and irrecoverably ambiguous host numbers where the SDK result model supports it.
#52 adds a broader operator inventory and matching contract, but its current head conflicts with this PR in the canonical specification. Its equality rules also omit the measured boolean gate and require only ASCII folding.
A simple union is not sufficient. The final specification must preserve #52's broader inventory while using this PR's measured equality, stringification, and operator-specific normalization rules.
property_matching.rs L212-220 short-circuits exact/is_not on truthiness whenever the filter value is boolean-ish, before any stringification, so on the service a filter value of false matches properties "banana", 0, null, {} and "" (all fall through is_truthy_property_value to false, giving "false" == "false"), whereas the stringify-then-Unicode-lowercase-then-compare rule specified here returns false for all five.
In rust/feature-flags/src/properties/property_matching.rs the truthy/falsy branch at line 212 runs before the array ANY-membership branch at line 222, so a filter array whose elements are all boolean-ish never reaches ANY: exact with value ["true","false"] returns false against property "true" and true against property "pro" on the service (verified by running the ported compute_exact_match, is_truthy_or_falsy_property_value, and is_truthy_property_value bodies), the opposite of both results the new "exact SHALL match when ANY element equals" rule specifies.
openspec/specs/local-feature-flag-evaluator/spec.md:320 states "An empty array therefore SHALL NOT match exact and SHALL match is_not", but in rust/feature-flags/src/properties/property_matching.rs the truthy check at L212 runs before the array ANY-membership branch at L222, and both is_truthy_or_falsy_property_value (L659-663) and is_truthy_property_value (L683-687) return true for [] because Rust's .all() over an empty iterator is true - so exact [] actually returns true against property values true, "true", "TRUE", [] and [true] (and false only for "us", null, false, 1), verified by compiling those helpers verbatim with serde_json; the existing test_match_properties_exact_empty_array_never_matches (L958-975) passes only because it probes the single non-matching input json!("us").
to_string_representation (rust/feature-flags/src/properties/property_matching.rs:54-61) delegates to serde_json's Value::to_string(), which pins one spelling that "JSON lexical representation" does not: JSON 0.0000001 stringifies to 1e-7 (Python json.dumps gives 1e-07, Ruby to_json gives 1.0e-07), -0.0 to -0.0 (JS String(-0) gives 0, Go %v gives -0), arrays to compact [1,2] (Python json.dumps gives [1, 2]), and object keys sorted because serde_json's default Map is a BTreeMap ({"b":1,"a":2} becomes {"a":2,"b":1}), so an exact filter "1e-7" against property 0.0000001 matches on /flags but returns false in a spec-conformant SDK that stringifies with its host JSON encoder.
The "ς" vs "Σ" scenario does not discriminate simple from full Unicode lowercase (Rust, Python, Node, Go strings.ToLower and Ruby String#downcase all return false for it), so a word-final vector is needed: property_matching.rs:230-231 compares to_string_representation(value).to_lowercase(), and an exact filter "οδος" against property "ΟΔΟΣ" matches on the service (Rust lowercases to οδος, U+03C2) but not in Go strings.ToLower or Ruby downcase (both οδοσ, U+03C3, verified on go1.26.2 and Ruby 2.6.10/3.3.0, and that is what posthog-rubylib/posthog/feature_flags.rb:391-400 uses today); the same is true for "İ", which Go lowercases to bare i where Rust, Python, Node and Ruby give i + U+0307.
Worth knowing that #52 (open, approved, same base dfddf1a) rewrites this same file and conflicts with it (git merge-tree --write-tree 626468b9 59ed9896 gives CONFLICT (content) in openspec/specs/local-feature-flag-evaluator/spec.md), and its spec.md:504/:525 require only "at least ASCII" folding for both operator families where this PR pins full Unicode for exact/is_not and ASCII-only for the search family; executing match_property shows this PR's split is the accurate one (exact"ß" vs "ẞ" true via str::to_lowercase at rust/feature-flags/src/properties/property_matching.rs:230-231, icontains"ß" vs "ẞx" false via to_ascii_lowercase at :253), so the correct union is this PR's two folding requirements plus #52's operator-inventory, numeric, date, semver and cohort requirements, but the headings differ so nothing dedupes them and whichever lands second silently drops the other's rules.
openspec validate --specs --strict and openspec validate --all --strict both pass 62/62. The proposed future backend cleanup remains separate in PostHog/posthog#90694; this PR documents the released comparator.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Local feature flag evaluators can disagree with the released
/flagsservice because the existing specification does not fully define boolean coercion, canonical value stringification, or Unicode lowercase. This is part of the fix for PostHog/posthog#78019.The released matcher applies a surprising boolean-like gate before ordinary array membership. False-like filters match arbitrary falsey properties, and an empty filter array is truthy because Rust's
all()is vacuously true.PostHog/posthog#90694 is a separate draft that proposes replacing that behavior. SDKs must continue matching the released backend until that proposal is accepted and merged.
Changes
exactandis_not.serde_jsonoutput for compact composites, sorted object keys, negative zero, and measured exponent cutovers.Overlap with #52
#52 adds a broader operator inventory and matching contract, but its current head conflicts with this PR in the canonical specification. Its equality rules also omit the measured boolean gate and require only ASCII folding.
A simple union is not sufficient. The final specification must preserve #52's broader inventory while using this PR's measured equality, stringification, and operator-specific normalization rules.
Testing
openspec validate --specs --strict- 62 specifications passed.openspec validate --all --strict- 62 specifications and archived artifacts passed.autoreview --mode local- no actionable findings.The original text passed structural validation despite contradicting the backend. The updated scenarios now record the measured discriminator vectors.
Changeset
Updated the archived OpenSpec proposal, design, tasks, delta, and the canonical local feature flag evaluator specification.