Skip to content

docs(flags): align unsupported cohort groups with backend - #207

Closed
marandaneto wants to merge 2 commits into
mainfrom
docs/cohort-group-backend-parity
Closed

marandaneto wants to merge 2 commits into
mainfrom
docs/cohort-group-backend-parity

Conversation

@marandaneto

Copy link
Copy Markdown
Member

Problem

The cross-SDK comparison during review of posthog-go#431, followed by posthog-go#440's sibling-order discussion, exposed a contract mismatch.

The SDK spec allowed a later decisive sibling to override an unsupported cohort group. The Rust feature-flags backend instead stops with a parsing error when that group is reached. This PR makes backend parity the contract for this case. It concerns the Rust backend in PostHog/posthog, not the Rust SDK.

Changes

  • Require immediate server fallback when traversal reaches an unsupported cohort group type, such as XOR. Do not evaluate later siblings after that failure.
  • Preserve definition-order short-circuiting. A decisive earlier sibling can finish the group before reaching the invalid group.
  • Keep three-valued composition for missing property context, valid group semantics, and negation safety unchanged.
  • Sync the complete delta into the canonical spec and archive the OpenSpec artifacts in this PR.
  • Clarify the existing descriptive acceptance matrix and add all four unsupported-first/decisive-first examples. No executable opt-in tags or new harness step phrases are added.

Source evidence: Rust cohort parsing and evaluation.

SDK impact

  • Go #440 restores the hard fallback instead of the sibling-composition behavior added during review. Normal supported definitions are unaffected.
  • Node #5649 already preserves this boundary and short-circuits earlier decisive siblings.
  • Python at 1f185fd still visits later siblings after recording a decisive result. It needs a separate short-circuit follow-up for the decisive-first cases. This is a source comparison, not a Python runtime conformance result.

Validation

  • Strict validation passed for all 69 canonical OpenSpec capabilities.
  • Verified that the archived modified requirement exactly matches the synced canonical requirement and no active change remains.
  • Parsed the full Gherkin feature and checked all four new matrix rows.
  • Go's updated backend-parity regressions failed before reversing the behavior and pass all 36 cases afterward. Go build, vet, and the full race-enabled suite passed.
  • Final isolated autoreview found no actionable findings.

The local harness checkout has neither the v2 Gherkin runner nor its step catalogue. The acceptance examples remain descriptive and unselected. Syntax validation is not a claim of harness readiness or cross-SDK conformance. No Rust backend runtime tests were run for this spec-only change.

Agent context

Autonomy: Human-driven (agent-assisted)

Prepared with Pi using the OpenSpec CLI, Git, GitHub CLI, and isolated autoreview. The user chose current backend behavior as the contract. Kept missing-context composition separate from unsupported-group failure and recorded the known SDK follow-ups. Human review is required before merge.

@marandaneto marandaneto self-assigned this Nov 10, 2026
@marandaneto
marandaneto marked this pull request as ready for review November 10, 2026 16:13
@marandaneto
marandaneto requested a review from a team as a code owner November 10, 2026 16:13
@marandaneto
marandaneto requested a review from a team November 10, 2026 16:14
@marandaneto

Copy link
Copy Markdown
Member Author

SDK cohort backend-parity audit: PR #207

Summary

All 19 listed canonical repositories were fetched from origin/main at 2026-11-10T16:14:54.145997+00:00. A final remote-tip check at 2026-11-10T16:28:32.391301+00:00 confirmed all 19 main SHAs were still unchanged. Many checked-out branches were outdated or dirty; none were used as the audited source.

Contract: SDK-specs #207, head 9c2273c06ae652fcad8c7354bbcbc4cb4455fdbb. Scope is the new reached-unsupported-cohort-group / child-order contract, not full local-feature-flag conformance.

Seven SDK implementations diverge directly from the cohort-order contract. Go and Node pass the core cohort matrix, but an additional outer flag-condition boundary can discard the required hard-fallback signal. The other ten repositories have no independent applicable local cohort evaluator on their exposed paths.

These are conformance results for synthetic unsupported definitions. No deployed XOR-emitting backend configuration was established, and no claim is made that ordinary valid definitions are currently wrong.

Fix PR tracker

These are nine suggested SDK fix PRs/work items, not PRs created by this audit. Seven fix the cohort engine directly; Go and Node address the separate outer flag-condition boundary described below.

Repository Proposed PR scope Existing PR handling
PostHog/posthog-dotnet Distinguish unsupported groups from ordinary inconclusive leaves; preserve their hard failure through groups, references and flag conditions. SDK fix PR needed; no existing PR selected by this audit.
PostHog/posthog-android (posthog-server) Preserve unknown group types during decode; reject them before the empty shortcut; propagate a hard fallback signal without breaking missing-context composition. JVM server SDK fix PR needed, not an Android client change.
PostHog/posthog-elixir Replace eager child mapping with ordered short-circuiting; preserve reached hard errors through outer property composition. SDK fix PR needed.
PostHog/posthog-php Reject reached unsupported group types before empty handling and preserve hard fallback through references/negation and caller boundaries. SDK fix PR needed.
PostHog/posthog-python Return immediately for decisive supported-group results; keep missing-context uncertainty composable and preserve reached hard errors at the flag-condition boundary. SDK fix PR needed.
PostHog/posthog-ruby Validate group type before empty handling; use a non-composable failure for unsupported groups and retain it through caller boundaries. SDK fix PR needed.
PostHog/posthog-rs Reject unknown group types rather than treating them as AND; short-circuit decisive results; preserve hard-error classification through the flag evaluator. Rust SDK fix PR needed, not a Rust backend change.
PostHog/posthog-go Keep the now-correct cohort ordering; prevent the outer release-condition loop from discarding a reached unsupported-group hard error. #440 is merged. Needs a new follow-up PR, not an update to #440.
PostHog/posthog-js (packages/node) Keep the now-correct cohort ordering; preserve reached hard errors across outer release-condition processing. #5649 is merged. Needs a new follow-up PR, not an update to #5649.

Node #5648 is also merged. Its nested-not_in correction does not itself need changing based on this audit. SDK-specs #207 carries the contract/report; implementation changes belong in the SDK repositories.

No current open PRs in the other repositories were nominated or reviewed as fix vehicles. “PR needed” means an implementation work item, not a claim that no other open PR could incorporate it.


Applicable backend implementations

SDK Audited main Status Concrete gap Evidence
.NET 5517dd559c diverges Unsupported group uses ordinary inconclusive error; a later decisive sibling suppresses the required fallback. Public API / fake HTTP reproduced
Java/Kotlin server (posthog-server) 88112924a5 diverges Decoder turns XOR into a null type; evaluator treats it as OR. Empty XOR matches before type validation. Source-only
Elixir efa8de2435 diverges Enum.map evaluates all children eagerly; even decisive-first groups visit XOR and require remote evaluation. Evaluator reproduced; public fallback source-traced
PHP 101762a290 diverges Unknown group types act as OR, and empty XOR matches. Reached invalid membership can become a match through negation. Real group/flag evaluators reproduced; public Client source-traced
Python 79f3559ca9 diverges Stores a decisive result but keeps visiting siblings. A later XOR unnecessarily forces fallback instead of being skipped. Public API / mocked network reproduced
Ruby 43d2b92ce7 diverges Nonempty XOR raises a composable error that enclosing groups can swallow; empty XOR returns true before validation. Real group/flag evaluator and poller fallback reproduced
Rust SDK (not the Rust backend) a48373a784 diverges Every non-OR type acts as AND; empty XOR matches. Supported groups also keep visiting siblings after a decisive result. Unmodified complete evaluator module reproduced; public HTTP path source-traced
Go 5f936d6e13 core matrix follows; outer boundary gap Merged cohort-order fix follows #207, but outer release-condition processing can discard or demote a reached hard error and skip remote fallback. Public API / stub HTTP reproduced
Node (packages/node) 49804c01e2 core matrix follows; outer boundary gap Merged cohort-order fix follows #207; same hard-error loss exists in the outer release-condition loop. Source-only

Required ordering and observed distinctions

Let X be a nested group with unsupported type XOR, T a matching supported group, F a mismatching supported group, and M ordinary missing-property context.

  • OR[X,T] and AND[X,F] must stop at X and require server evaluation.
  • OR[T,X] must return true and AND[F,X] must return false without reaching X.
  • Empty XOR must be rejected before the empty-values shortcut.
  • OR[M,T] and AND[M,F] remain conclusive. Missing context must not be blanket-converted into a hard error.
  • Reached unsupported-group failure must survive cohort references and negation.

.NET, PHP, Ruby, JVM and Rust SDK fail unsupported-first cases. Python and Elixir preserve that final fallback but fail decisive-first short circuit. Rust additionally evaluates after decisive results; a scalar-descendant witness demonstrates the visit even when a simple XOR case happens to return the expected final boolean.

Additional outer-boundary gap (Go/Node and others)

Go #440 and Node #5649 are present on the fetched main branches. Their cohort-group loops follow the core matrix. This is not evidence that those fixes failed to merge.

However, under #207's wording that a reached unsupported group must stop local evaluation, the outer flag-condition loops can still lose the hard failure:

  1. A condition with filters [XOR cohort reference, ordinary mismatching property] returns local false with zero remote evaluation calls.
  2. An earlier missing property can demote the reached hard error; a later unconditional release condition then returns local true with zero remote calls.

Go's public GetFeatureFlag behavior was reproduced at its exact main SHA. Node has the same code path by source inspection; no Node runtime reproduction was run. Equivalent outer-boundary cases were also reproduced/source-traced in .NET, Elixir, Python, PHP and Ruby. The individual reports distinguish evaluator-level, public API and source-only proof.

This is a separate ownership boundary from cohort sibling composition. A correction must preserve the hard unsupported-group signal there without indiscriminately changing existing missing-context/static-cohort handling. It does not justify whole-tree prevalidation: an earlier decisive cohort sibling must still short-circuit.

Remaining repositories: applicability, not conformance passes

Repository Audited main Why no separate local-cohort fix is identified
PostHog/posthog-flutter d788d2cd6e Remote-only flag evaluation; no local cohort-rule interpreter.
PostHog/posthog-ios d237d54a4a Remote /flags evaluation and cached results; no local cohort-rule interpreter.
PostHog/posthog-unity c440a45b3a Remote /flags evaluation and cached results; no local cohort-rule interpreter.
PostHog/posthog-kmp 8e8e881a32 Delegates JVM calls to the client-side com.posthog core, not posthog-server; no owned local cohort interpreter.
PostHog/posthog-godot 21ec1c3d60 Remote /flags evaluation; caches already evaluated flags.
PostHog/posthog-unreal 97d187e20e Native FFI calls posthog-rs evaluate_flags with default remote options; no cohort interpreter in this repository.
PostHog/posthog-roblox cef6b1e8db Server-side Roblox SDK, but remote-only flag evaluation; no local cohort interpreter.
PostHog/posthog-pi 87fc23d14c Analytics/MCP integration; PostHog Node client factory supplies no local-evaluation API key; no owned cohort interpreter.
PostHog/posthog-openclaw 058eff6d17 Analytics integration; PostHog Node client factory supplies no local-evaluation API key; no owned cohort interpreter.
PostHog/posthog-opencode 7281f0a171 Analytics integration; Node client factory supplies no local-evaluation API key; no owned cohort interpreter.

The Android mobile client itself is remote/cached-value based; its separate posthog-server JVM module is included in the applicable-backend table. Roblox is server-side at runtime, but flags are remote-evaluated. Pi, OpenClaw and OpenCode are integrations using posthog-node rather than independent evaluator implementations. Unreal delegates through posthog-rs and exposes no separate cohort engine; its current client construction does not supply local-evaluation configuration.

Validation and limits

  • .NET: public EvaluateFlagsAsync plus fake HTTP reproduced the conformance gaps. 36 observed-outcome/existing tests and 2 decode tests ran; this is not 38 passing conformance cases.
  • Elixir: unmodified exported LocalEvaluator executed in 19 observed-outcome assertions. Full Mix/HTTP integration suite not run.
  • PHP/Ruby: actual exported evaluators ran 19 case records each. Ruby additionally exercised the poller with a stubbed remote transport. Full PHPUnit/RSpec suites not run.
  • Python: public snapshot/legacy behavior exercised with mocked networking; 3 existing cohort tests passed.
  • Rust SDK: complete unmodified feature_flags.rs compiled in an isolated offline crate; runtime probes and 14 existing cohort tests ran. Full client HTTP integration not run.
  • Go: existing focused tests passed; a public getter probe reproduced the outer-boundary leak with a stub server.
  • JVM and Node: source-only findings, not runtime reproduced.
  • Parent independently inspected critical source paths and reproduction evidence. Passing observed-outcome probes record actual divergent behavior and are not claims that the contract passed.

No retained SDK source edits, commits, SDK PR changes, issues, or fixes were made. This audit comment is the publication action. Owned disposable .NET/Go validation worktrees were removed; user changes in primary checkouts were preserved. Fetching the requested latest mains changed remote-tracking refs only.

Reproduction commands and evidence boundaries

Validation performed on the immutable main snapshots

The temporary audit probes assert and record observed behavior, including incorrect outcomes. Their passing status does not mean the SDK conforms.

  • .NET: dotnet test tests/UnitTests/UnitTests.csproj --configuration Release --framework net8.0 --no-restore --nologo --filter 'FullyQualifiedName~TheScopedCohortParityAudit|FullyQualifiedName~TheUnsupportedCohortFilters' --logger 'console;verbosity=normal'. Ran 17 temporary public API/fake-HTTP audit cases and 19 existing tests. A separate decode selector ran two existing round-trip tests.
  • Elixir: ran an isolated script requiring the unmodified exported LocalEvaluator/result modules. Nineteen observed-outcome assertions passed; public HTTP fallback was source-traced rather than integration-tested.
  • PHP/Ruby: ran isolated repro.php and repro.rb against the real exported SDK evaluators, producing 19 case records per language. Ruby additionally exercised its single-flag poller with stubbed transport. PHPUnit/RSpec suites were not run.
  • Python: ran an isolated probe importing the exact exported SDK modules, with mocked public network calls. Also ran python -B -m pytest posthog/test/test_feature_flags.py::TestCohortMatching -p no:cacheprovider -q --timeout=30: three existing tests passed.
  • Rust SDK: cargo run --offline --quiet and cargo test --offline --quiet cohort in a tiny external crate importing the complete unmodified pinned feature_flags.rs. Fourteen existing tests passed. This is evaluator execution, not a full async/blocking client HTTP integration test.
  • Go: GOPROXY=off GOSUMDB=off go test -count=1 -run 'Test(UnsupportedCohortChildComposition|CohortSiblingOrderPreservesBackendFallback|MatchPropertyGroupRawAndParsedParity|MatchPropertyGroupTypes)$' . passed. A separate temporary TestAuditReachedXORAtFlagConditionBoundary exercised the public getter with stub HTTP and recorded the outer-boundary fallback losses.
  • JVM and Node: source-only. No Gradle/JVM runtime setup or Node monorepo install/build was performed.

All validation that wrote tests or build files used owned disposable worktrees or unique external source exports. The .NET and Go validation worktrees were removed afterward. Dirty/outdated primary checkouts and their staged files were not changed.

Minimal reusable input

For email=user@x:

{
  "X": {"type":"XOR","values":[{"type":"person","key":"email","operator":"exact","value":"other@x"}]},
  "T": {"type":"AND","values":[{"type":"person","key":"email","operator":"exact","value":"user@x"}]},
  "F": {"type":"AND","values":[{"type":"person","key":"email","operator":"exact","value":"other@x"}]}
}

Use each expression as a referenced dynamic cohort in one active flag with 100% rollout. Test OR[X,T], AND[X,F], OR[T,X], AND[F,X], empty XOR[], references/negation, and missing-property controls. Unsupported-first must retain server fallback; decisive-first must not visit X. Public tests should assert the local-only result and the number of remote evaluation calls, not just the internal boolean.

For the separate outer-boundary probe, use cohort 42=X and release-condition properties [cohort42, email exact other@x]. Go, Node and several other SDKs can currently return a conclusive false after actually reaching X. A second probe with a missing property before cohort42 and a later unconditional condition can similarly turn the reached hard failure into a local true. These are distinct from the supported cohort sibling loops.


Audit action: report only. No SDK fixes, commits, PR creations, issues or review-state changes were made by this audit.

@haacked haacked self-assigned this Nov 10, 2026

@haacked haacked left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we should copy this behavior from the Rust /flags service.

OR is commutative, so A OR B should always equal B OR A. But in the Rust implementation, the result depends on child order: OR[true, XOR group] is true, while OR[XOR group, true] errors because the loop returns the parse error with ? before it reaches the true. That looks like an accident to me and not meant to be a contract.

What do you think about fixing the Rust service instead? It could treat an unsupported group as unknown, so another child in the same group can still decide the result, and fail the flag only when it can't. Then the SDKs could keep the spec as it was before this PR.

If you agree, I'd be happy to do it.

@marandaneto

Copy link
Copy Markdown
Member Author

I'm not sure we should copy this behavior from the Rust /flags service.

OR is commutative, so A OR B should always equal B OR A. But in the Rust implementation, the result depends on child order: OR[true, XOR group] is true, while OR[XOR group, true] errors because the loop returns the parse error with ? before it reaches the true. That looks like an accident to me and not meant to be a contract.

What do you think about fixing the Rust service instead? It could treat an unsupported group as unknown, so another child in the same group can still decide the result, and fail the flag only when it can't. Then the SDKs could keep the spec as it was before this PR.

If you agree, I'd be happy to do it.

wfm PostHog/posthog#133410

What do you think about fixing the Rust service instead?

agree, i was unsure about the correct behaviour since its a product question, the sdk spec was just 'PRs over issues' in case that was the right approach but happy to pivot

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.

2 participants