Skip to content

docs(flags): define local property matching semantics - #51

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

marandaneto merged 2 commits into
mainfrom
fix/issue-78019-case-folding

Conversation

@marandaneto

@marandaneto marandaneto commented Aug 27, 2026 •

Copy link
Copy Markdown
Member

Problem

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.

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.

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

Copy link
Copy Markdown

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.

@arnohillen

Copy link
Copy Markdown

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.

@arnohillen

Copy link
Copy Markdown

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").

@arnohillen

Copy link
Copy Markdown

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.

@arnohillen

arnohillen commented Aug 27, 2026 •

Copy link
Copy Markdown

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-ruby lib/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.

@arnohillen

Copy link
Copy Markdown

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.

@marandaneto

Copy link
Copy Markdown
Member Author

Addressed the current-backend findings in signed commit ea2002c.

  • Boolean gate: the contract now records that false-like filters compare aggregate truthiness before stringification.
  • Boolean arrays: all-boolean-like arrays now take the aggregate branch before ordinary ANY membership.
  • Empty arrays: the scenarios now capture Rust all() vacuous truth and the resulting exact/is_not matrix.
  • Stringification: the contract now pins compact arrays, sorted object keys, negative zero, and the measured serde_json number forms.
  • Unicode: word-final sigma and dotted-I vectors now distinguish Rust full lowercase from simple lowercase and casefolding.
  • PR docs(local-evaluation): define canonical matching criteria #52: the PR body records the content conflict and semantic overlap. It also explains why a simple union is insufficient.

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.

@marandaneto
marandaneto merged commit 2e1ce63 into main Aug 31, 2026
10 checks passed
@marandaneto
marandaneto deleted the fix/issue-78019-case-folding branch August 31, 2026 07:53
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.

3 participants