Mixed User + Group Targeting for Feature Flags Mega Issue
@dmarticus is already working on this.
Since Jan 29, 2026.
- Dominant language
- Python
- Stars
- 39.9k
- Forks
- 3.4k
- Avg merge
- 6h 51m
- Merged PRs (30d)
- 232
Description
This issue is for tracking work related to https://github.com/PostHog/posthog/issues/33206
Context
Customers want flags like:
- "All users in company X, plus specific users by distinct ID"
- "The 'beta-testers' project group, plus any user with
is_employee = true" - "50% of enterprise companies, but only pro-tier users within those companies"
- "50% of companies OR 75% of beta users" (different rollout logic per condition)
This requires combining group-level and person-level conditions in a single flag, with per-condition-set aggregation.
Key Insight: Groups Are Event-Contextual
Groups are attached to events, not persons. A person doesn't "belong to" a group permanently — they have an active group context at evaluation time:
- User Alice sends events with
{ company: "acme" }when logged into Acme - Same Alice sends events with
{ company: "beta-corp" }when logged into Beta Corp - The SDK already provides this context at evaluation time
This means we don't need to persist person→group membership. The caller controls which group context is active, which naturally solves the "user has multiple orgs" problem.
Design
Per-Condition-Set Aggregation
Each condition set chooses its own aggregation mode:
| Mode | Hash Key | Rollout Means |
|---|---|---|
| Person | distinct_id |
"X% of users matching this condition" |
| Group | group_key |
"X% of groups matching this condition" |
This enables mixed targeting like:
Condition Set 1:
filters: group.merchant.key IN ['acme', 'beta-corp']
rollout: 100%
aggregation: merchant
→ "All users in these specific merchants"
Condition Set 2:
filters: distinct_id IN ['alice', 'bob']
rollout: 100%
aggregation: person
→ "These specific users"
Result: user sees flag if they match EITHER condition
Model Change
Move aggregation_group_type_index from flag-level to condition-level:
# Before (flag-level)
class FeatureFlag:
aggregation_group_type_index: Optional[int]
filters: dict # contains condition sets
# After (condition-level)
class FeatureFlagCondition:
properties: List[Property]
rollout_percentage: Optional[int]
variant: Optional[str]
aggregation_group_type_index: Optional[int] # NEW
# None = person (hash distinct_id)
# 0-4 = group (hash that group type's key)
Migration: Existing flags copy their flag-level aggregation_group_type_index to all condition sets.
Evaluation Flow
┌─────────────────────────────────────────────────────────────────┐
│ Flag Evaluation Request │
│ distinct_id: "user-123" │
│ person_properties: { plan: "pro", is_beta_tester: true } │
│ groups: { company: "acme", project: "proj-456" } │
│ group_properties: { company: { size: "enterprise" }, ... } │
└──────────────────────────────┬──────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ For Each Condition Set │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Condition Set 1: │ │
│ │ filters: group.company.size = 'enterprise' │ │
│ │ rollout: 50% │ │
│ │ aggregation: company (index=0) │ │
│ │ │ │
│ │ 1. Check filters → match! │ │
│ │ 2. hash_key = groups[0] = "acme" │ │
│ │ 3. hash("acme") < 50%? → Yes │ │
│ │ 4. Return TRUE │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ OR │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Condition Set 2: │ │
│ │ filters: person.is_beta_tester = true │ │
│ │ rollout: 75% │ │
│ │ aggregation: person (index=None) │ │
│ │ │ │
│ │ 1. Check filters → match! │ │
│ │ 2. hash_key = distinct_id = "user-123" │ │
│ │ 3. hash("user-123") < 75%? → ... │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ First matching condition set wins → return result │
└──────────────────────────────────────────────────────────────────┘
Example Scenarios
Scenario 1: Specific merchants + specific users
Condition Set 1:
filters: group.merchant.key IN ['acme', 'beta-corp']
rollout: 100%
aggregation: merchant
Condition Set 2:
filters: distinct_id IN ['alice', 'bob']
rollout: 100%
aggregation: person
Result: All users in acme/beta-corp see the flag, plus alice and bob regardless of merchant.
Scenario 2: 50% of enterprise companies OR 75% of beta users
Condition Set 1:
filters: group.company.size = 'enterprise'
rollout: 50%
aggregation: company
Condition Set 2:
filters: person.is_beta_tester = true
rollout: 75%
aggregation: person
Result: Users in 50% of enterprise companies see the flag. Additionally, 75% of beta testers see it (even if their company wasn't selected or isn't enterprise).
Scenario 3: Mixed filters within one condition (person + group filters together)
Condition Set 1:
filters: person.plan = 'pro' AND group.company.size = 'enterprise'
rollout: 50%
aggregation: company
Result: 50% of enterprise companies are selected. Within those, only pro users see the flag.
UI Changes
Each condition set shows its own aggregation selector:
┌─────────────────────────────────────────────────────────────────┐
│ Condition Set 1 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Match users where: │ │
│ │ company :: size = 'enterprise' │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Roll out to [50%] of [Companies ▼] │
│ ├─ Users │
│ ├─ Companies │
│ └─ Projects │
└─────────────────────────────────────────────────────────────────┘
OR
┌─────────────────────────────────────────────────────────────────┐
│ Condition Set 2 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Match users where: │ │
│ │ is_beta_tester = true │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Roll out to [75%] of [Users ▼] │
└─────────────────────────────────────────────────────────────────┘
What We're NOT Building
-
❌ Denormalized
group_X_keycolumns on persons table — Groups are event-contextual, not person-contextual. A person can be in different groups across different sessions, so persisting "membership" doesn't match the data model. -
❌ New person-group membership tables — Same reason. The SDK already provides group context at evaluation time; we'd be duplicating data that can go stale.
-
❌ Ingestion pipeline changes to track group membership — No membership to track. The group context is ephemeral and provided per-request.
-
❌ Person merge handling for group membership — Since we're not persisting membership, there's nothing to merge.
-
❌ Nested rollout ("50% of groups, then 75% of people within those") — Significantly more complex evaluation logic and confusing UX. Per-condition-set aggregation with OR logic covers the known use cases without this complexity.
Blast Radius
Each condition set has its own blast radius, displayed per-set:
Person aggregation:
SELECT uniqExact(person_id) as affected_users
FROM events
WHERE team_id = :team_id AND [filters]
Display: "~1,234 users"
Group aggregation:
SELECT
uniqExact($group_0) as affected_groups,
uniqExact(person_id) as affected_users
FROM events
WHERE team_id = :team_id AND [filters]
Display: "~50 companies (~1,234 users)"
Combined display for a flag with multiple condition sets:
Condition Set 1: ~50 companies (~1,234 users)
Condition Set 2: ~500 users
Total reach: ~1,600 users (estimated, may overlap)
Tasks
Schema & Migration
- Add
aggregation_group_type_indexto condition set model - Migrate existing flags: copy flag-level aggregation to all condition sets
- Update API serializers
Flag Evaluation (Rust Service)
- Read aggregation from condition set, not flag
- Allow conditions to contain both person and group property filters
- Evaluate group properties against provided
group_propertiescontext - Use correct hash key per condition set
Frontend
- Add per-condition-set aggregation selector
- Allow selecting group properties in conditions regardless of aggregation
- Update blast radius display per condition set
Blast Radius (HogQL)
- Update queries to handle mixed property conditions
- Return per-condition-set estimates
SDK Local Evaluation
- Update local evaluation to read aggregation per condition set
- Apply group property filters from provided context
- Use correct hash key per condition set
Documentation
- Document per-condition-set aggregation
- Add examples for common B2B use cases
Open Questions
-
Validation: If a condition set has group property filters but the SDK call doesn't provide that group type, should we return
false(strict) or skip that condition set (permissive)? -
Blast radius overlap: When multiple condition sets match overlapping users, should we try to estimate unique reach or just show per-set numbers with an "may overlap" note?
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.