Skip to content

feat(Feature Versioning): add multi variate capabilities to the experimental update-flag endpoints#7955

Draft
emyller wants to merge 10 commits into
mainfrom
feat/multivariate-update-flag-endpoints
Draft

feat(Feature Versioning): add multi variate capabilities to the experimental update-flag endpoints#7955
emyller wants to merge 10 commits into
mainfrom
feat/multivariate-update-flag-endpoints

Conversation

@emyller

@emyller emyller commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Thanks for submitting a PR! Please check the boxes below:

  • I have read the Contributing Guide.
  • I have added information to docs/ if required so people know about the feature.
  • I have filled in the "Changes" section below.
  • I have filled in the "How did you test this code" section below.

Changes

Contributes to #7642

  • Refactors update-flag code for better typing and maintenance
  • Introduces MV (multi variate) support for both experimental update-flags endpoints.
  • Behaviour is consistent to UX in the dashboard.
  • Makes a few properties optional, to allow for isolated changes to MVs — and other properties too, why not.

How did you test this code?

Unit tests.

@emyller emyller self-assigned this Jul 6, 2026
@vercel

vercel Bot commented Jul 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

3 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs Ignored Ignored Preview Jul 8, 2026 10:37pm
flagsmith-frontend-preview Ignored Ignored Preview Jul 8, 2026 10:37pm
flagsmith-frontend-staging Ignored Ignored Preview Jul 8, 2026 10:37pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This pull request renames the "update-flag-v1"/"v2" endpoints, serializers, and services to "Option A"/"Option B" naming throughout the API layer. It introduces typed TypedDict payload schemas and new dataclasses (FeatureValue, FlagChangeSetOptionA/B, MultivariateOptionChangeSet) replacing prior untyped/string-based structures. The versioning service is refactored around shared helpers for applying enabled/value/multivariate changes, including new environment-level multivariate option reconciliation logic. Experimentation rollout code, URL routing, views, tests, OpenAPI specs, and documentation are updated to match the new naming and typed structures.

Estimated code review effort: 4 (Complex) | ~60 minutes


Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added api Issue related to the REST API docs Documentation updates feature New feature or request and removed docs Documentation updates labels Jul 6, 2026
@emyller emyller force-pushed the feat/multivariate-update-flag-endpoints branch from 420e419 to d64e257 Compare July 7, 2026 01:00
@github-actions github-actions Bot added docs Documentation updates feature New feature or request and removed feature New feature or request docs Documentation updates labels Jul 7, 2026
Comment thread api/tests/integration/conftest.py
@emyller emyller force-pushed the feat/multivariate-update-flag-endpoints branch from d64e257 to 23e4464 Compare July 8, 2026 02:33
@github-actions github-actions Bot added the docs Documentation updates label Jul 8, 2026
@github-actions github-actions Bot added feature New feature or request and removed feature New feature or request docs Documentation updates labels Jul 8, 2026
@github-actions github-actions Bot added docs Documentation updates and removed feature New feature or request docs Documentation updates labels Jul 8, 2026
@github-actions github-actions Bot added feature New feature or request and removed docs Documentation updates labels Jul 8, 2026
@emyller emyller force-pushed the feat/multivariate-update-flag-endpoints branch from 7f75746 to 8d5469c Compare July 8, 2026 22:20
@github-actions github-actions Bot added docs Documentation updates feature New feature or request and removed feature New feature or request docs Documentation updates labels Jul 8, 2026
@github-actions github-actions Bot added docs Documentation updates feature New feature or request and removed feature New feature or request docs Documentation updates labels Jul 8, 2026
@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.63%. Comparing base (a7b0d21) to head (8c25092).

Additional details and impacted files
@@           Coverage Diff            @@
##             main    #7955    +/-   ##
========================================
  Coverage   98.63%   98.63%            
========================================
  Files        1496     1498     +2     
  Lines       59072    59251   +179     
========================================
+ Hits        58266    58445   +179     
  Misses        806      806            

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@emyller emyller marked this pull request as ready for review July 8, 2026 23:19
@emyller emyller requested review from a team as code owners July 8, 2026 23:19
@emyller emyller requested review from khvn26 and removed request for a team July 8, 2026 23:19
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Docker builds report

Image Build Status Security report
ghcr.io/flagsmith/flagsmith-e2e:pr-7955 Finished ✅ Skipped
ghcr.io/flagsmith/flagsmith-api:pr-7955 Finished ✅ Results
ghcr.io/flagsmith/flagsmith:pr-7955 Finished ✅ Results
ghcr.io/flagsmith/flagsmith-private-cloud:pr-7955 Finished ✅ Results
ghcr.io/flagsmith/flagsmith-frontend:pr-7955 Finished ✅ Results
ghcr.io/flagsmith/flagsmith-api-test:pr-7955 Finished ✅ Skipped

@github-actions github-actions Bot added feature New feature or request and removed feature New feature or request labels Jul 8, 2026
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Playwright Test Results (oss - depot-ubuntu-latest-arm-16)

passed  6 passed

Details

stats  6 tests across 4 suites
duration  40.7 seconds
commit  8c25092
info  🔄 Run: #18118 (attempt 1)

Playwright Test Results (oss - depot-ubuntu-latest-16)

passed  3 passed

Details

stats  3 tests across 3 suites
duration  41.5 seconds
commit  8c25092
info  🔄 Run: #18118 (attempt 1)

Playwright Test Results (private-cloud - depot-ubuntu-latest-arm-16)

passed  4 passed

Details

stats  4 tests across 3 suites
duration  38 seconds
commit  8c25092
info  🔄 Run: #18118 (attempt 1)

Playwright Test Results (private-cloud - depot-ubuntu-latest-16)

passed  7 passed

Details

stats  7 tests across 6 suites
duration  35 seconds
commit  8c25092
info  🔄 Run: #18118 (attempt 1)

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Visual Regression

19 screenshots compared. See report for details.
View full report

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
api/features/feature_states/serializers.py (1)

238-254: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

N+1 query in validate_segment_overrides.

One Segment.objects.filter(...).exists() query per segment override via validate_segment_id. Already flagged with a TODO — worth batching into a single filter(id__in=segment_ids, project_id=...).count() check once out of experimentation, as noted.

♻️ Possible batched check
-        for segment_id in segment_ids:
-            self.validate_segment_id(segment_id)
+        valid_count = Segment.objects.filter(
+            id__in=segment_ids, project_id=_environment(self.context).project_id
+        ).count()
+        if valid_count != len(segment_ids):
+            raise serializers.ValidationError(
+                "One or more segments not found in project"
+            )

Happy to open this as a follow-up if useful.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 39f7d2ea-874a-4876-9a95-d80d6825179b

📥 Commits

Reviewing files that changed from the base of the PR and between a7b0d21 and 8c25092.

📒 Files selected for processing (18)
  • api/api/urls/experiments.py
  • api/experimentation/services.py
  • api/features/feature_states/serializers.py
  • api/features/feature_states/types.py
  • api/features/feature_states/views.py
  • api/features/versioning/dataclasses.py
  • api/features/versioning/versioning_service.py
  • api/tests/integration/conftest.py
  • api/tests/integration/environments/identities/test_integration_identities.py
  • api/tests/integration/features/versioning/test_update_flag_endpoints.py
  • api/tests/integration/helpers.py
  • api/tests/unit/features/feature_states/test_serializers.py
  • api/tests/unit/features/feature_states/test_unit_feature_states_views.py
  • api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py
  • docs/docs/deployment-self-hosting/observability/_events-catalogue.md
  • docs/docs/integrating-with-flagsmith/flagsmith-api-overview/admin-api/updating-flags.md
  • mcp/src/flagsmith_mcp/openapi.json
  • openapi.yaml

Comment on lines +145 to 179
def validate(self, attrs: UpdateFlagOptionAPayload) -> UpdateFlagOptionAPayload:
options = attrs.get("multivariate_options")
if options is None:
return attrs
if attrs.get("segment"):
_validate_segment_options(attrs["feature"], options)
else:
_validate_environment_options(attrs["feature"], options)
return attrs

@property
def flag_change_set(self) -> FlagChangeSet:
validated_data = self.validated_data
value_data = validated_data["value"]
def flag_change_set(self) -> FlagChangeSetOptionA:
validated_data: UpdateFlagOptionAPayload = self.validated_data
value = _feature_value(validated_data.get("value"))
segment_data = validated_data.get("segment")

return FlagChangeSet(
segment_id = segment_data.get("id") if segment_data else None
options = validated_data.get("multivariate_options")

multivariate_values = None
multivariate_options = None
if options is not None:
if segment_id is None:
multivariate_options = _option_change_sets(options)
else:
multivariate_values = _reweight_change_sets(options)

return FlagChangeSetOptionA(
author=AuthorData.from_request(self.context["request"]),
enabled=validated_data["enabled"],
feature_state_value=value_data["value"],
type_=value_data["type"],
segment_id=segment_data.get("id") if segment_data else None,
enabled=validated_data.get("enabled"),
value=value,
segment_id=segment_id,
segment_priority=segment_data.get("priority") if segment_data else None,
multivariate_values=multivariate_values,
multivariate_options=multivariate_options,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Segment-level multivariate reweight silently drops a submitted value.

UpdateFlagOptionASerializer reuses MultivariateOptionSerializer/MultivariateOptionPayload (which includes an optional value) for the segment-scoped reweight path (flag_change_set, lines 163-169). _validate_segment_options (lines 376-384) only checks that id is present and owned by the feature — it never rejects an accompanying value. _reweight_change_sets (lines 433-443) then only reads id/percentage_allocation, so any value a client sends for a segment-level entry is accepted by validation but silently discarded.

Option B avoids this entirely by using a dedicated SegmentOverrideMultivariateOptionPayload/SegmentOverrideMultivariateOptionSerializer that has no value field. Consider applying the same approach for Option A's segment path, or explicitly rejecting value in _validate_segment_options when present.

🐛 Proposed fix
 def _validate_segment_options(feature: Feature, options: _OptionPayloads) -> None:
     """Segment overrides can only re-weight options already on the feature."""
     if any("id" not in option for option in options):
         raise serializers.ValidationError(
             {"multivariate_options": "Segment overrides require an option 'id'."}
         )
+    if any(option.get("value") is not None for option in options):
+        raise serializers.ValidationError(
+            {
+                "multivariate_options": (
+                    "Segment overrides cannot set an option's 'value'; "
+                    "update the value at the environment level instead."
+                )
+            }
+        )
     if error := _option_ownership_error(feature, _option_ids(options)):
         raise serializers.ValidationError({"multivariate_options": error})

Also applies to: 376-384, 433-443

Comment on lines +290 to +303
def apply_feature_state_changes(
feature_state: FeatureState,
*,
enabled: bool | None,
value: FeatureValue | None,
multivariate_values: list[MultivariateValueChangeSet] | None,
) -> None:
"""Apply only the provided parts of a change; omissions are left intact."""
if enabled is not None:
feature_state.enabled = enabled
feature_state.save()
if value is not None:
_update_feature_state_value(feature_state.feature_state_value, value)
update_multivariate_values(feature_state, multivariate_values)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Make apply_feature_state_changes atomic for direct callers.

update_multivariate_values() can raise after enabled / value have already been saved. update_flag* callers are wrapped, but api/experimentation/services.py:_update_rollout_in_place calls this helper directly, so invalid MV allocations can leave a partially updated state.

Proposed fix
 def apply_feature_state_changes(
     feature_state: FeatureState,
     *,
     enabled: bool | None,
     value: FeatureValue | None,
     multivariate_values: list[MultivariateValueChangeSet] | None,
 ) -> None:
     """Apply only the provided parts of a change; omissions are left intact."""
-    if enabled is not None:
-        feature_state.enabled = enabled
-        feature_state.save()
-    if value is not None:
-        _update_feature_state_value(feature_state.feature_state_value, value)
-    update_multivariate_values(feature_state, multivariate_values)
+    with transaction.atomic():
+        if enabled is not None:
+            feature_state.enabled = enabled
+            feature_state.save()
+        if value is not None:
+            _update_feature_state_value(feature_state.feature_state_value, value)
+        update_multivariate_values(feature_state, multivariate_values)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def apply_feature_state_changes(
feature_state: FeatureState,
*,
enabled: bool | None,
value: FeatureValue | None,
multivariate_values: list[MultivariateValueChangeSet] | None,
) -> None:
"""Apply only the provided parts of a change; omissions are left intact."""
if enabled is not None:
feature_state.enabled = enabled
feature_state.save()
if value is not None:
_update_feature_state_value(feature_state.feature_state_value, value)
update_multivariate_values(feature_state, multivariate_values)
def apply_feature_state_changes(
feature_state: FeatureState,
*,
enabled: bool | None,
value: FeatureValue | None,
multivariate_values: list[MultivariateValueChangeSet] | None,
) -> None:
"""Apply only the provided parts of a change; omissions are left intact."""
with transaction.atomic():
if enabled is not None:
feature_state.enabled = enabled
feature_state.save()
if value is not None:
_update_feature_state_value(feature_state.feature_state_value, value)
update_multivariate_values(feature_state, multivariate_values)

Comment on lines +327 to +332
else:
option_id = option.id
if option.value is not None:
mv_option = feature.multivariate_options.get(id=option_id)
mv_option.set_value(option.value.value, option.value.type_)
mv_option.save()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep existing option default allocations in sync.

New options persist default_percentage_allocation, but existing options only update their value; changing option.percentage_allocation leaves MultivariateFeatureOption.default_percentage_allocation stale.

Proposed fix
         else:
             option_id = option.id
+            mv_option = feature.multivariate_options.get(id=option_id)
             if option.value is not None:
-                mv_option = feature.multivariate_options.get(id=option_id)
                 mv_option.set_value(option.value.value, option.value.type_)
+            mv_option.default_percentage_allocation = option.percentage_allocation
+            mv_option.save()
-                mv_option.save()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
else:
option_id = option.id
if option.value is not None:
mv_option = feature.multivariate_options.get(id=option_id)
mv_option.set_value(option.value.value, option.value.type_)
mv_option.save()
else:
option_id = option.id
mv_option = feature.multivariate_options.get(id=option_id)
if option.value is not None:
mv_option.set_value(option.value.value, option.value.type_)
mv_option.default_percentage_allocation = option.percentage_allocation
mv_option.save()

Comment on lines +198 to +209
def test_update_flag__segment_override__updates_multivariate_options(
admin_client: APIClient,
environment_api_key: str,
feature: int,
mv_option_value: str,
mv_option_50_percent: int,
segment: int,
# segment_featurestate: int,
versioned_environment: Environment,
endpoint: str,
payload: Callable[[int, int, int], FeatureUpdatePayload],
) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove leftover commented-out parameter.

# segment_featurestate: int, on line 205 appears to be a debug/leftover artifact.

🧹 Proposed fix
     mv_option_value: str,
     mv_option_50_percent: int,
     segment: int,
-    # segment_featurestate: int,
     versioned_environment: Environment,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_update_flag__segment_override__updates_multivariate_options(
admin_client: APIClient,
environment_api_key: str,
feature: int,
mv_option_value: str,
mv_option_50_percent: int,
segment: int,
# segment_featurestate: int,
versioned_environment: Environment,
endpoint: str,
payload: Callable[[int, int, int], FeatureUpdatePayload],
) -> None:
def test_update_flag__segment_override__updates_multivariate_options(
admin_client: APIClient,
environment_api_key: str,
feature: int,
mv_option_value: str,
mv_option_50_percent: int,
segment: int,
versioned_environment: Environment,
endpoint: str,
payload: Callable[[int, int, int], FeatureUpdatePayload],
) -> None:

Comment on lines +425 to +505
pytest.param(
"update-flag-v1",
lambda feature, segment: {
"feature": {"id": feature},
"segment": {"id": segment},
"multivariate_options": [
{
"percentage_allocation": 50,
"value": {"type": "string", "value": "variant"},
}
],
},
"require an option 'id'",
id="option_a-segment-option-without-id",
),
pytest.param(
"update-flag-v1",
lambda feature, segment: {
"feature": {"id": feature},
"segment": {"id": segment},
"multivariate_options": [{"id": 999999, "percentage_allocation": 50}],
},
"do not belong to the feature",
id="option_a-segment-unknown-option",
),
pytest.param(
"update-flag-v2",
lambda feature, segment: {
"feature": {"id": feature},
"segment_overrides": [
{
"segment_id": segment,
"multivariate_options": [
{"id": 999999, "percentage_allocation": 50}
],
},
],
},
"do not belong to the feature",
id="option_b-segment-unknown-option",
),
pytest.param(
"update-flag-v1",
lambda feature, segment: {
"feature": {"id": feature},
"segment": {"id": segment},
"multivariate_options": [
{"id": 999999, "percentage_allocation": 20},
{"id": 999999, "percentage_allocation": 30},
],
},
"must be unique",
id="option_a-segment-duplicate-option",
),
pytest.param(
"update-flag-v2",
lambda feature, segment: {
"feature": {"id": feature},
"segment_overrides": [
{
"segment_id": segment,
"multivariate_options": [
{"id": 999999, "percentage_allocation": 20},
{"id": 999999, "percentage_allocation": 30},
],
},
],
},
"must be unique",
id="option_b-segment-duplicate-option",
),
pytest.param(
"update-flag-v1",
lambda feature, segment: {
"feature": {"name": "missing-feature"},
"multivariate_options": [{"id": 999999, "percentage_allocation": 50}],
},
"not found in project",
id="option_a-unknown-feature",
),
],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Segment-override and unknown-feature validation cases lack Option B counterparts.

option_a-segment-option-without-id, option_a-segment-unknown-option, option_a-segment-duplicate-option, and option_a-unknown-feature only test the update-flag-v1 (Option A) endpoint. Since the PR's goal is parity between Option A and Option B for multivariate support, adding matching update-flag-v2 (Option B) parametrizations for these validation paths would close the coverage gap and confirm both endpoints share the same validation guarantees.

return attrs


class UpdateFlagOptionASerializer(BaseFeatureUpdateSerializer[FeatureState]):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Option A and Option B are becoming the new semantic for v1 and v2 ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

To the public, they already are. The refactor here happens for:

  1. Eliminate ambiguity, i.e. flag-update v1/v2 over feature versioning v1/v2.
  2. Make it consistent with user docs.
👻Only certain spirits are expected to have different names depending on the realm they're in, but these are just code.

@emyller emyller marked this pull request as draft July 9, 2026 15:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Issue related to the REST API feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants