Repository navigation
feat(flags): honor experiment holdouts in local flag evaluation - #360
Conversation
Local evaluation ignored `filters.holdout`, so an identifier inside an experiment's holdout was bucketed into a regular variant and disagreed with the server, silently and with no fallback. Holdout resolution now runs after the inactive-flag check and before release conditions, returning the synthetic `holdout-<id>` variant. Membership uses the flag-level bucketing identity (group key for group-aggregated flags, device id for device-bucketed flags, distinct id otherwise) and the server's hash of `holdout-<value>`: the "-" separator, not the "." used by rollout hashing, selects a different population. Percentages are clamped to 0-100 without truncating fractions, compared inclusively, and 100 includes everyone without hashing. A holdout missing `id` or `exclusion_percentage` is skipped, leaving ordinary evaluation unchanged. Tested: go build, go vet, gofmt, make api-diff, and go test -race -count=1 ./... at the repository root. Co-Authored-By: Claude Opus 5 <[email protected]> Generated-By: PostHog Desktop Task-Id: 2887d167-f4e8-446a-a056-202893f6ecd9
posthog-go Compliance ReportDate: 2026-10-17 01:52:58 UTC ✅ All Tests Passed!111/111 tests passed Capture_V1 Tests✅ 94/94 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
Nothing to fix here. Went through the holdout path against the flags server and posthog-python, and the order (after the inactive check, before release conditions), the One gap: payload lookup for a |
dustinbyrne
left a comment
There was a problem hiding this comment.
The holdout implementation otherwise looks sound, including precedence, hashing, clamping, payload lookup, and definition replacement. Consider addressing the missing-device case noted inline: partial holdouts currently resolve locally using distinct ID despite unavailable required device identity.
AI-assisted review.
| if holdoutVariant, held := getHoldoutVariant(flag, bucketingId); held { | ||
| return holdoutVariant, nil |
There was a problem hiding this comment.
[P1 / IMPORTANT] Do not assign a partial holdout using distinct ID when required device identity is missing
Location: featureflags.go:1229–1230.
An active person-level flag with bucketing_identifier: "device_id" and a complete partial holdout can resolve conclusively without a device ID.
For example, after loading a flag with holdout ID 727 at 20%:
client.GetFeatureFlag(posthog.FeatureFlagPayload{
Key: "checkout",
DistinctId: "user-1",
OnlyEvaluateLocally: true,
})getBucketingID substitutes "user-1" when DeviceId is nil (featureflags.go:1187–1192). The new holdout branch hashes that substituted identity and returns "holdout-727", nil before release conditions: the pinned "user-1" hash is approximately 0.17805599206573022.
This is publicly reachable input. Validation requires a key and distinct ID, but does not require DeviceId (feature_flag_config.go:29–68).
Governing contract: The pinned holdout bucketing requirement explicitly says missing required identity must follow inconclusive/fallback rules rather than substitute an unrelated identity. Python’s resolve_bucketing_value independently rejects unavailable device identity.
Base-to-head causality: Ordinary missing-device substitution predates this PR; it is not independently reported here. This diff newly applies it to holdout membership. If an ordinary release condition requires an unavailable region property, base evaluation reaches that condition and remains inconclusive. Head instead returns a successful holdout assignment before checking it, suppressing otherwise eligible fallback.
The shared path affects single getters, GetAllFlags, EvaluateFlags and its context wrapper, capture enrichment with SendFeatureFlags, and supported same-context dependencies. Snapshot population stores the assignment as locally resolved; access events can report it with locally_evaluated: true. The public FeatureFlagResult itself does not expose that marker.
Impact qualification: This can select a holdout population using the wrong identity and conceal unavailable evaluation context. However, pinned Rust backend code also substitutes distinct ID when device identity is missing. This is therefore a canonical-contract violation—not a demonstrated nil-device disagreement with current backend master. Remote evaluation may return the same string.
Consider returning an inconclusive error before partial holdout hashing when required device context is missing or empty. Scope the check to usable, person-level device-bucketed holdouts; preserve group bucketing, incomplete-holdout skipping, ordinary no-holdout behavior, and the hash-free full-holdout path.
Regression sketch — not executed
This exact unit regression uses the helpers already added in featureflags_holdouts_test.go:
func TestHoldoutMissingDeviceIsInconclusive(t *testing.T) {
poller := &FeatureFlagsPoller{}
flag := holdoutFlag(&Holdout{
ID: float64(727),
ExclusionPercentage: floatPtr(20),
})
flag.BucketingIdentifier = stringPtr(bucketingIdentifierDevice)
flag.Filters.Groups[0].Properties = []FlagProperty{{
Key: "region",
Type: "person",
Operator: "exact",
Value: "USA",
}}
value, err := poller.computeFlagLocally(
flag, "user-1", nil, nil, nil, nil, nil,
)
var inconclusive *InconclusiveMatchError
require.ErrorAs(t, err, &inconclusive)
require.NotEqual(t, "holdout-727", value)
}Expected: Inconclusive required-context error.
Head result implied by source: "holdout-727", nil; the assertions fail.
Proposed command, not run: go test -count=1 -run '^TestHoldoutMissingDeviceIsInconclusive$' .
For public-entry regression coverage:
- Serve the same definition from
/flags/definitions, including the unavailableregioncondition and ordinarycontrol/testvariants. - Create a client with a fixture-only secret key and mock endpoint.
- Count
/flagsrequests atomically and return{"featureFlags":{"checkout":false}}. - Disable single-getter access events to isolate evaluation traffic.
| Action | Expected after fix | Head behavior implied by source |
|---|---|---|
| Local-only getter, no device ID | Inconclusive error; no remote request | Holdout string; nil error |
| Getter with fallback allowed | One remote request; mocked false |
No request; holdout string |
Local-only EvaluateFlags, scoped to checkout |
checkout absent from Keys() |
Holdout assignment present |
| Snapshot evaluation with fallback allowed | Remote response fills checkout |
Assignment remains locally resolved |
The remote fixture verifies fallback eligibility, not deployed backend assignment.
Retain controls for explicit device "user-1"; device "user-5" with matching region properties; group-key bucketing without a device ID; absent/incomplete holdouts; and empty device identity.
These regressions were not executed; the described results are derived from static source inspection.
There was a problem hiding this comment.
🤖 An AI agent wrote this on behalf of @ioannisj.
Fixed in 32151f5. getHoldoutVariant now takes the distinct and device ids itself and returns an InconclusiveMatchError when a device-bucketed person flag has a partial holdout and no device id, so the distinct id can't stand in for the device identity in the holdout hash and the caller falls back to the server like any other missing context.
Full holdouts, incomplete holdouts, no-holdout flags and group flags behave as before. TestHoldoutMissingDeviceIsInconclusive covers the nil and empty device id cases and fails on c0937ee.
Kept it scoped to holdouts: the ordinary distinct id fallback in getBucketingID predates this PR, and posthog-node's blanket inconclusive for any device-bucketed flag without a device id is I think worth its own change.
|
This PR hasn't seen activity in a week! Should it be merged, closed, or further worked on? If you want to keep it open, post a comment or remove the |
marandaneto
left a comment
There was a problem hiding this comment.
The code looks good; no qualifying findings. Verdict: correct.
💡 Motivation and Context
Contract:
local-feature-flag-evaluator— "Experiment holdouts precede release conditions" and "Holdout membership uses backend-compatible bucketing" (added in sdk-specs#76). Acceptance:acceptance/private/local-feature-flag-holdouts.feature.Why: the SDK compliance matrix in PostHog/sdk-specs records this as an open
Local Feature Flag Evaluatorgap for posthog-go:The matrix calls holdouts "the highest-value single fix in the matrix right now": it is a silent correctness divergence, not a missing feature. A flag configured with a holdout was evaluated locally as though the holdout did not exist, so every identity inside the holdout got an answer that disagreed with the backend — no error, no fallback, and experiment results contaminated by users who should have been excluded. The definition loader already preserved the data; the evaluator simply never read it.
What changed
FiltergainsHoldout *Holdout(id,exclusion_percentage), so the field already present on the wire is decoded.matchFeatureFlagPropertiesresolves the holdout right after the flag-level bucketing id is computed and before any condition is evaluated, returning the syntheticholdout-<id>variant. That variant does not need to appear infilters.multivariate.variants. The inactive-flag short circuit incomputeFlagLocallystill wins, so an inactive flag with a holdout staysfalse.holdout-<bucketing_value>:calculateHashis refactored intocalculateHashWithSeparator, andholdoutHashjoins with-rather than the.used for rollout hashing. Neither the flag key nor the holdout id participates. The separator is the whole point — the dot-separated hash is equally uniform and deterministic while holding out a different set of people than the server.exclusion_percentageis clamped to 0–100 without truncating fractions, compared inclusively (hash <= pct/100), and a clamped 100 includes everyone without hashing.idorexclusion_percentageis skipped and ordinary evaluation continues, matching posthog-python's_get_holdout_variant.Single-flag, bulk (
GetAllFlags), and dependency evaluation all route throughmatchFeatureFlagProperties, so they share these semantics automatically — a flag depending oncheckout == "holdout-727"compares against the holdout string.Backwards compatibility
Additive.
Holdoutis a new exported type and a new field onFilter; no existing field, method, or signature changes, andapi/public-api.txtis regenerated accordingly. Behavior only changes for flags whose definitions actually carryfilters.holdout— and for those, the previous answer was wrong. Flags without a holdout take an extra nil check and are otherwise untouched. The API-shape agreement is the published sdk-spec, per CONTRIBUTING.md.💚 How did you test it?
New
featureflags_holdouts_test.gocovers the spec's scenarios: holdout beating unavailable person properties, 0% rollout and a variant override; inactive flag stayingfalse; no-holdout and incomplete-holdout flags keeping their ordinary assignment; partial membership pinned to the server's hash vectors (user-1→ 0.17805599206573022 held out,user-5→ 0.6563813925994418 not); inclusive and clamped percentage boundaries; flag-level group identity and device-id bucketing each overriding the distinct id; the holdout value flowing into a dependent flag; holdout fields surviving definition decoding and a refresh that removes them; and numeric and string ids both rendering asholdout-727.Run at the repository root:
go build ./...,go vet ./...,gofmt -l .,make api-diff, andgo test -race -count=1 ./...— all green. No nested-module (otel/,posthogmcpsdk/) code is touched. No manual testing against a live project was performed.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset file (.changeset/local-eval-holdouts.md,minor)Follow-up work
Evaluate Flagsnegative-knowledge retention, and theFeature Flag Called Trackersession-attribution allowlist.🤖 Agent context
Autonomy: Fully autonomous
Produced by an unattended Claude Code run that reads the compliance matrices in PostHog/sdk-specs and implements one open, backwards-compatible gap per run. This gap was chosen over the other open posthog-go candidates because the matrix ranks it highest-value, the verdict is backward-compatible, and the spec pins the exact hash, precedence, and clamping rules — leaving little room for interpretation. posthog-python's implementation was read as the reference for the skip-incomplete-holdout and clamping semantics; its hash vectors are reused as test fixtures. Deliberately left alone: the
calculateHashfast path (refactored to take a separator rather than duplicated) and every non-holdout evaluation rule.Agent-authored — requires human review; not self-merged.
Created with PostHog Desktop
🤖 Generated with Claude Code