The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
22 KiB
Feature enrollment migration: super_groups → feature_enrollment
Problem
The super_groups field in feature flag filters reuses FlagPropertyGroup,
a type designed for regular conditions.
This creates semantic confusion:
propertiesalways contains exactly one entry —$feature_enrollment/{flag_key}withexactoperator and["true"]valuerollout_percentageis always100— the type suggests partial rollouts, but that's never used- The name "super groups" doesn't appear in the product UI — users see "Early access features" and "Feature enrollment"
- Payloads are larger than necessary — a complex nested structure where a boolean would suffice
Current production usage
| Region | Flags with super_groups | Teams |
|---|---|---|
| US | 976 | 419 |
| EU | 582 | 282 |
Higher usage than the holdout migration (~20 rows). Requires batched migration.
Target format
Before:
{
"super_groups": [
{
"properties": [
{
"key": "$feature_enrollment/new-dashboard",
"type": "person",
"operator": "exact",
"value": ["true"]
}
],
"rollout_percentage": 100
}
]
}
After:
{
"feature_enrollment": true
}
Why the enrollment key is derivable from the flag key
The enrollment property is always $feature_enrollment/{flag_key} — this invariant is enforced at every layer:
- Every write path constructs it from the flag key:
f"$feature_enrollment/{feature_flag_key}"inproducts/early_access_features/backend/api.py(bothcreate()andupdate()) - On flag key rename,
_update_super_groups_for_key_change()ininsights/api/feature_flag.pyrewrites the enrollment key to match the new flag key - Migration 0748 (
insights/migrations/0748_update_featureflag_super_groups.py) was specifically written to fix historical cases where the enrollment key drifted out of sync — it forced$feature_enrollment/{flag.key}for every active flag
There is no case where a flag's super_groups references a different flag's enrollment key. The data is fully redundant with flag.key, so evaluation code can construct the key at runtime (same pattern as the Rust service constructing holdout-{id} for holdouts).
Design decisions
-
Boolean
feature_enrollment, not a string array. Since the enrollment key is derivable (see above), no additional data is needed. A boolean is the simplest representation: "this flag has feature enrollment enabled." -
feature_enrollmentinstead offeature_enrollments. A flag either has feature enrollment or it doesn't — it's not a collection. The singular form matches the boolean semantics. -
Not a model field. Keeping it in
filtersJSON avoids a schema migration and keeps the flag definition self-contained for the Rust service (which reads filters from Redis/Postgres JSON, not Django model fields).
Migration phases
Phase 1: Django writes both formats (backwards-compatible)
Status: MERGED
Why first: Writing is always safer to change first —
it doesn't affect what's currently being read.
If we update Rust first to read feature_enrollment before Django writes it,
there's a deployment window where newly created enrollments would only have super_groups
while Rust prefers feature_enrollment.
Tasks:
- Add
feature_enrollmentfield support toFlagFiltersserializer in Django - Update
super_conditionslambda inproducts/early_access_features/backend/api.pyto also setfeature_enrollment: true - Update
_update_super_groups_for_key_changeininsights/api/feature_flag.py— added comment notingfeature_enrollmentis key-independent - Update
pre_deletehook inproducts/early_access_features/backend/apps.py - Add
has_feature_enrollmentproperty toFeatureFlagmodel - Tests: all 28 early access feature tests + 1 feature flag key update test pass
Phase 2: Rust reads new format with fallback
Status: MERGED
Depends on: Phase 1 deployed
Tasks:
- Add
feature_enrollment: Option<bool>field toFlagFiltersstruct in Rust - Add
FlagFilters::enrollment_key(flag_key)shared helper to derive$feature_enrollment/{flag_key}- Used by both evaluation logic and
requires_db_properties— avoids format string duplication
- Used by both evaluation logic and
- Update super condition evaluation in
flag_matching.rsto preferfeature_enrollmentoversuper_groups- If
feature_enrollment == Some(true): derive enrollment key via helper, check person properties directly - Else if
super_groupspresent: legacy fallback viais_super_condition_match - Else: no enrollment, proceed to normal conditions
- If
- Update
requires_db_propertiesinflag_filters.rsto handlefeature_enrollment- Takes
flag_keyparameter; checks if overrides contain the derived enrollment key
- Takes
SuperConditionEvaluationkept as-is (still used by legacy fallback, removed in Phase 4)- Tests:
- Parameterized
test_feature_enrollment_match_by_property_value— 4 cases: string"true"/"false", booleantrue/false test_feature_enrollment_no_property_falls_through— person without enrollment property falls through to regular conditionstest_feature_enrollment_takes_precedence_over_super_groups— both formats present, new format winstest_feature_enrollment_requires_db_properties_when_override_missing/test_feature_enrollment_skips_db_when_override_present— unit tests forrequires_db_properties- All 6 existing
super_groupstests still pass (legacy fallback)
- Parameterized
- Updated all
FlagFiltersstruct literals across the codebase to includefeature_enrollment: None
Phase 3: Backfill existing flags
Status: MERGED (1076 was no-op due to bug, 1078 fix verified in production)
Depends on: Phase 2 deployed (Rust fallback handles both formats)
Tasks:
- Write a Django data migration to convert all existing flags
- Batched
RunPythonwith.iterator(chunk_size=250)andbulk_updatein batches of 250 elidable=True,noopreverse, try/except per flag with logging- Defensive Python guards:
isinstance(super_groups, list), skip empty, skip already-updated
- Batched
- Migration 1076 deployed but silently updated 0 rows —
filters__super_groups__isnull=Falsegenerates unquoted JSON key in SQL (filters -> super_groupsinstead offilters -> 'super_groups'), causing the queryset to match nothing - Migration 1078 fix deployed with corrected queryset:
filters__has_key="super_groups"+.exclude(super_groups=None)+.exclude(super_groups=[]).extra(where=["NOT (filters ? 'feature_enrollment' AND filters->>'feature_enrollment' = 'true')"])for thefeature_enrollmentexclusion (ORMexclude(filters__feature_enrollment=True)also generates broken SQL with unquoted keys — it excludes ALL rows instead of none)
- Verified in production: ~900 rows updated in both US and EU
Lesson learned: Django's JSONField ORM lookups on nested keys (filters__some_key=value,
filters__some_key__isnull=False) can generate SQL with unquoted key names.
Use has_key, exclude(field=None), exclude(field=[]), and .extra(where=[...]) with raw SQL
for reliable JSON key filtering in migrations.
Phase 4: Remove legacy reading
Status: MERGED (#59100)
Depends on: Phase 3 complete + verified in production
Tasks:
- Remove
super_groupsfallback from Rust evaluationflag_matching.rs: only readfeature_enrollment- Remove
super_groupsfromFlagFiltersstruct (serde silently ignores unknown JSON keys) - Remove
is_super_condition_matchmethod (replaced by simpler enrollment check) - Remove
SuperConditionEvaluationstruct
- Remove
super_groupsreading from Django code - Update
flag_matching.pygate andis_super_condition_matchtogether to readfeature_enrollmentinstead ofsuper_groups- Replaced
is_super_condition_matchwithis_feature_enrollment_match: derives enrollment key from flag key, reads fromquery_conditionsdirectly - Replaced
_super_condition_matchesand_super_condition_is_setwith enrollment-aware equivalents - Kept
FeatureFlagMatchReason.SUPER_CONDITION_VALUE— renaming would change the API response string
- Replaced
- Remove
super_conditionsproperty fromfeature_flag.py - Tests: deleted 8 legacy super_groups unit tests, 2 filter tests, 1 operations test, 1 cache weigher test; converted 3 integration tests to
feature_enrollmentformat; renamed test functions fromsuper_conditiontofeature_enrollment; added parameterized Python test foris_feature_enrollment_matchcovering all 4 branches - Cleaned up all
super_groups: Nonestruct literals across Rust codebase
Note: SuperConditionValue reason code rename deferred — changing the serialized string "super_condition_value" would break the API response format. Can be revisited independently.
Note: One customer (team 361776) has 7 flags with non-standard super_groups keys (e.g. $feature/section-test instead of $feature_enrollment/...). They were emailed in early May to re-save their flags but never did. Their flags will stop matching on the enrollment condition but continue evaluating regular conditions normally.
Phase 5: Remove legacy writing
Status: not started
Depends on: Phase 4 deployed + no rollback needed
Tasks:
- Stop writing
super_groupsin early access feature serializer (products/early_access_features/backend/api.py)- Replace the
super_conditionslambda with simple"feature_enrollment": True - All 5 write sites: promote to active (update), create with active stage, GA rollout_to_all, demote/archive, destroy
- Replace the
- Remove
("super_groups", "rollout_percentage")fromROLLOUT_PERCENTAGE_PATHSininsights/approvals/actions/feature_flags.py- Feature enrollment doesn't have a rollout percentage concept — no replacement path needed
- Remove
_update_super_groups_for_key_change()frominsights/api/feature_flag.pyfeature_enrollmentis a boolean — no key-dependent data to update
- Remove
pre_deletesuper_groups cleanup fromproducts/early_access_features/backend/apps.py- Replace with
feature_enrollment: Nonecleanup
- Replace with
- Update test assertions that check
filters["super_groups"]:products/early_access_features/backend/test/test_early_access_feature.py(multiple tests)insights/api/test/test_feature_flag.py(test_updating_feature_flag_key_updates_super_groups— remove or rewrite)insights/approvals/tests/test_update_feature_flag_action.py
- Update frontend — see Frontend changes detail below
- Opportunistic cleanup: strip
super_groupsfromfilterson next flag save (update()ininsights/api/feature_flag.py)- Same pattern as
holdout_groupscleanup (line 1364)
- Same pattern as
- Run
insightscli build:openapito regenerate TypeScript types
Phase 6: Data cleanup
Status: not started
Depends on: Phase 5 deployed
Strip the super_groups key from stored filters JSON on existing flags. After Phase 5, no code reads or writes super_groups — the key is dead data in the JSON column.
Tasks:
- Write a Django data migration to remove
super_groupsfromfiltersJSON on all flags- Use batched approach: query flags with
filters__has_key="super_groups", pop the key,bulk_updatein batches elidable=Truesince this is purely a data cleanup- Consider doing this as a raw SQL migration for efficiency:
UPDATE insights_featureflag SET filters = filters - 'super_groups' WHERE filters ? 'super_groups'
- Use batched approach: query flags with
- Alternatively, rely on the opportunistic cleanup from Phase 5 (strip on save) and skip the migration entirely — dead data with no reader impact
Key files to modify
| Component | File | Phase |
|---|---|---|
| Django serializer schema | insights/api/feature_flag.py |
1, 5 |
| Early access feature serializer | products/early_access_features/backend/api.py |
1, 5 |
| Early access feature app hooks | products/early_access_features/backend/apps.py |
1, 5 |
| Feature flag model | products/feature_flags/backend/models/feature_flag.py |
1, 4 |
| Python flag matching | products/feature_flags/backend/flag_matching.py |
4 |
| Approvals change tracking | insights/approvals/actions/feature_flags.py |
5 |
| Rust flag models | rust/feature-flags/src/flags/flag_models.rs |
2 |
| Rust flag matching | rust/feature-flags/src/flags/flag_matching.rs |
2, 4 |
| Rust flag filters | rust/feature-flags/src/flags/flag_filters.rs |
2, 4 |
| Rust flag match reason | rust/feature-flags/src/flags/flag_match_reason.rs |
4 |
| Rust API types | rust/feature-flags/src/api/types.rs |
4 |
| Rust test utilities | rust/feature-flags/src/utils/test_utils.rs |
2, 4 |
| Rust flag operations tests | rust/feature-flags/src/flags/flag_operations.rs |
2, 4 |
| Rust flag service tests | rust/feature-flags/src/flags/flag_service.rs |
2, 4 |
| Rust flag matching tests | rust/feature-flags/src/flags/test_flag_matching.rs |
2, 4 |
| Rust integration tests | rust/feature-flags/tests/test_flags.rs |
2, 4 |
| Frontend types | frontend/src/types.ts |
5 |
| Frontend flag logic | frontend/src/scenes/feature-flags/featureFlagLogic.ts |
5 |
| Frontend release conditions logic | frontend/src/scenes/feature-flags/featureFlagReleaseConditionsLogic.ts |
5 |
| Frontend flag UI | frontend/src/scenes/feature-flags/FeatureFlag.tsx |
5 |
| Frontend flag overview | frontend/src/scenes/feature-flags/FeatureFlagOverviewV2.tsx |
5 |
| Data migration (backfill) | insights/migrations/XXXX_backfill_feature_enrollment.py |
3 |
| Data migration (cleanup) | insights/migrations/XXXX_strip_super_groups.py |
6 |
Frontend changes detail
Reminder: When starting Phase 5, read this section carefully. The frontend changes are a net simplification — the entire
isSupercode path throughfeatureFlagReleaseConditionsLogicbecomes dead code. Instead of rendering a complex property filter card for super conditions, you just show a boolean "Feature enrollment enabled" state.
Files to change
-
frontend/src/types.ts(line 3874)- Add
feature_enrollment?: booleantoFeatureFlagFilters - Remove
super_groups?: FeatureFlagGroupType[]
- Add
-
frontend/src/scenes/feature-flags/FeatureFlag.tsx(line 244)- Currently checks
featureFlag.filters.super_groups?.length > 0to render<FeatureFlagReleaseConditions readOnly isSuper> - Replace with
featureFlag.filters.feature_enrollmentand render a simpler indicator (no need for the full release conditions component) - The existing TODO comment at line 243 already calls for this cleanup
- Currently checks
-
frontend/src/scenes/feature-flags/FeatureFlagOverviewV2.tsx(line 343)- Same pattern — checks
super_groups?.length > 0to render<FeatureFlagSuperConditionsReadonly> - Replace with a simpler display for
feature_enrollment: true
- Same pattern — checks
-
frontend/src/scenes/feature-flags/featureFlagLogic.ts(line 363)cleanFlag()runscleanFilterGroupsonsuper_groupsto stripsort_key— remove entirely, a boolean has no sort keys
-
frontend/src/scenes/feature-flags/featureFlagReleaseConditionsLogic.ts(lines 59, 82, 513)isSuperprop, the key derivation using it, and thefilterGroupsselector branch that returnsfilters.super_groups— all dead code, remove- The entire
isSupercode path can be deleted
-
frontend/src/scenes/feature-flags/FeatureFlagReleaseConditions.tsx(lines 93, 608, 787)isSuperprop, title branching ("Super release conditions"), andrenderSuperReleaseConditionGroup— all removable
-
frontend/src/scenes/feature-flags/FeatureFlagReleaseConditionsReadonly.tsx(line 227)FeatureFlagSuperConditionsReadonlycomponent passesisSuper: true— can be simplified or removed
-
frontend/src/scenes/surveys/surveyLogic.tsx(line 1785)- Sets
super_groups: undefinedwhen constructing filter objects — change tofeature_enrollment: undefined
- Sets
How to test the frontend changes
Test each of these flows manually in the browser:
- Create early access feature in ALPHA/BETA stage → open the linked feature flag → confirm it shows "Feature enrollment enabled" (or the new UI) instead of the old super condition property card
- Promote CONCEPT → ALPHA → flag should now show feature enrollment indicator
- Promote to GA with "rollout to all" → flag should clear enrollment indicator, show 100% rollout in regular conditions
- Demote ALPHA → CONCEPT → enrollment indicator should disappear
- Archive an early access feature → enrollment indicator should disappear
- Delete an early access feature → enrollment indicator should disappear from the flag
- Rename a flag key that has enrollment → confirm nothing breaks (no enrollment key to update anymore — boolean is key-independent)
- Feature flag overview page (V2) → repeat the above checks for the readonly display
- Survey with targeting flag → confirm surveys still render correctly
Risks and mitigations
Cache staleness after backfill migration
Risk: bulk_update doesn't fire post_save signals, so HyperCache may serve stale data.
Mitigation: Phase 2's Rust fallback reads super_groups when feature_enrollment is missing, so stale cache entries behave identically. Cache refreshes naturally on TTL expiry. No explicit invalidation needed.
Deployment ordering between Django and Rust
Risk: If Rust is deployed before Django, new enrollments created during the gap only have super_groups.
Mitigation: Deploy Django first (Phase 1), then Rust (Phase 2). The fallback in Rust handles both.
Higher row count than holdout migration
Risk: ~1,558 flags vs ~20 for holdouts. Migration takes longer.
Mitigation: Use batched bulk_update with batch_size=500. Still small enough for a synchronous migration (< 2,000 rows). No need for async migration.
Reason code rename in API responses
Risk: Renaming super_condition_value to feature_enrollment_value in flag match reasons changes the API response.
Mitigation: Check if any SDK or dashboard depends on this string. If so, keep super_condition_value as the serialized value (Rust serde rename) while using the new internal name. Alternatively, defer the rename to a separate PR.
Frontend backward compatibility
Risk: If a user has an old frontend loaded that reads super_groups, the display may break after Phase 5.
Mitigation: Phase 5 frontend changes should handle both formats gracefully during rollout. The feature_enrollment boolean is additive; old frontends simply won't render it (they'll see no super_groups). New frontends check feature_enrollment first.
Existing data migration 0748
Risk: Migration 0748_update_featureflag_super_groups.py fixes $feature_enrollment keys in super_groups. After Phase 5, this migration references a removed field.
Mitigation: This migration is already applied in production and won't run again. No action needed — it's historical.
Coordination with holdout_groups cleanup
Risk: Both this migration and holdout opportunistic cleanup touch filters JSON.
Mitigation: No conflict — they operate on different keys. The opportunistic strip pattern is independent per key.
Notes from review
- The "only evaluate first super group" pattern (
super_groups[0]) is now implicit in the boolean — there's nothing to index - Feature enrollment is not supported by local evaluation — SDKs ignore
super_groups, so no SDK changes needed - Static cohort creation from flags ignores
super_groups— no changes needed there - The Python flag matching code in
insights/models/feature_flag/flag_matching.pyis legacy but still actively used for database-backed evaluation. Unlike holdouts, super_groups ARE evaluated in this code path — Phase 4 must update it. - The Rust service constructs the enrollment property key (
$feature_enrollment/{flag_key}) internally from the flag key — this is an implementation detail, not data
Differences from holdout migration
| Aspect | Holdout migration | Feature enrollment migration |
|---|---|---|
| Scale | ~20 rows | ~1,558 rows |
| Target format | {id, exclusion_percentage} object |
true boolean |
| Key derivation | holdout-{id} variant from holdout ID |
$feature_enrollment/{flag_key} from flag key |
| Python flag_matching.py | Ignores holdouts (legacy code) | Actively evaluates super_groups — must update |
| Frontend impact | Minimal | Moderate — several components render super conditions |
| Approvals impact | Had exclusion_percentage path | Remove rollout_percentage path (no replacement) |
Future cleanup (out of scope)
- Consider removing the
$feature_enrollment/{key}person property pattern entirely and using a dedicated enrollment model. This would decouple enrollment tracking from person properties, but is a much larger change. - The
FeatureFlagReleaseConditionscomponent has a TODO comment (FeatureFlag.tsx:244) about cleaning up super_groups rendering — Phase 5 addresses this.