Skip to content

fix(features): inherit mv hashing salt in change requests and scheduled commits#7963

Closed
Zaimwa9 wants to merge 16 commits into
mainfrom
fix/7913-mv-hashing-salt-inheritance
Closed

fix(features): inherit mv hashing salt in change requests and scheduled commits#7963
Zaimwa9 wants to merge 16 commits into
mainfrom
fix/7913-mv-hashing-salt-inheritance

Conversation

@Zaimwa9

@Zaimwa9 Zaimwa9 commented Jul 8, 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 #7913

Builds on #7914. Two recreation paths bypass clone() and still re-bucketed multivariate variants:

  • v1 change request / scheduled change commits: drafts are new rows, so every commit re-dealt variants. ChangeRequestCommitService now carries the superseded live state's salt onto drafts at commit time.
  • Recreating a segment override in a draft version: this tripped the new guard with an unactionable 400. The version feature state serializer now inherits the salt from the previous version's matching override.

Also: succession helpers on FeatureState (used by clone()), a features.feature_state.mv_variants_rebucketed warning event for unknown v1 recreation paths (added to the events catalogue), and clone() copies multivariate values in id order so variant order is deterministic.

Alternatives considered

  • Implicit inheritance (BEFORE_CREATE hook): covers unknown paths too, but heals them silently — a flow bypassing clone() would never surface and be fixed. Rejected in favour of declared, reviewable call sites plus the guard tripwire (raise under v2, warning event under v1).
  • Routing all recreation through clone() (copy the old state, apply the request on top): the request already defines the entire new state, so the clone would contribute nothing but the salt — while starting from old values instead of defaults silently changes what partial requests return, and the rewired create path would touch shared serialisers far beyond the two affected flows. Possible later refactor.

How did you test this code?

New unit tests: variant assignment stable across a change request commit (fails without the fix), override delete + recreate in a draft inherits the salt via the API, warning event fires only for unknown v1 paths, lineage lookup helper.

@vercel

vercel Bot commented Jul 8, 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 13, 2026 9:42am
flagsmith-frontend-preview Ignored Ignored Preview Jul 13, 2026 9:42am
flagsmith-frontend-staging Ignored Ignored Preview Jul 13, 2026 9:42am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: fe8e2ef7-cbab-46a2-9240-9a9636b11e22

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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 fix and removed docs Documentation updates fix 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 (04a287e) to head (003520e).

Additional details and impacted files
@@                    Coverage Diff                     @@
##           fix/7913-mv-hashing-salt    #7963    +/-   ##
==========================================================
  Coverage                     98.63%   98.63%            
==========================================================
  Files                          1497     1498     +1     
  Lines                         59132    59256   +124     
==========================================================
+ Hits                          58326    58450   +124     
  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.

@github-actions github-actions Bot added the docs Documentation updates label Jul 8, 2026
@github-actions github-actions Bot added fix docs Documentation updates and removed fix docs Documentation updates labels Jul 8, 2026
@Zaimwa9 Zaimwa9 changed the title fix(features): inherit mv hashing salt at non-clone recreation sites fix(features): inherit mv hashing salt in change requests and scheduled commits Jul 8, 2026
@Zaimwa9 Zaimwa9 marked this pull request as ready for review July 13, 2026 08:03
@Zaimwa9 Zaimwa9 requested review from a team as code owners July 13, 2026 08:03
@Zaimwa9 Zaimwa9 requested review from emyller and removed request for a team July 13, 2026 08:03
@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

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

passed  4 passed

Details

stats  4 tests across 3 suites
duration  10.5 seconds
commit  9a426e3
info  🔄 Run: #18179 (attempt 1)

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

passed  3 passed

Details

stats  3 tests across 3 suites
duration  38.4 seconds
commit  9a426e3
info  🔄 Run: #18179 (attempt 1)

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

passed  21 passed
skipped  1 skipped

Details

stats  22 tests across 17 suites
duration  1 minute, 30 seconds
commit  9a426e3
info  🔄 Run: #18179 (attempt 1)

Skipped tests

firefox › tests/initialise-tests.pw.ts › Signup › Create Organisation and Project @oss

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

passed  5 passed

Details

stats  5 tests across 4 suites
duration  41.8 seconds
commit  9a426e3
info  🔄 Run: #18179 (attempt 1)

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

passed  4 passed

Details

stats  4 tests across 3 suites
duration  42 seconds
commit  003520e
info  🔄 Run: #18186 (attempt 1)

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

passed  1 passed

Details

stats  1 test across 1 suite
duration  31.2 seconds
commit  003520e
info  🔄 Run: #18186 (attempt 1)

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

passed  4 passed

Details

stats  4 tests across 3 suites
duration  47.6 seconds
commit  003520e
info  🔄 Run: #18186 (attempt 1)

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

passed  4 passed

Details

stats  4 tests across 2 suites
duration  21.7 seconds
commit  003520e
info  🔄 Run: #18186 (attempt 1)

@github-actions

github-actions Bot commented Jul 13, 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: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a33a51d7-3983-4c2c-ba3f-33e68a14e676

📥 Commits

Reviewing files that changed from the base of the PR and between 56359f8 and 9a426e3.

📒 Files selected for processing (9)
  • api/core/workflows_services.py
  • api/features/models.py
  • api/features/versioning/serializers.py
  • api/tests/unit/features/test_unit_features_models.py
  • api/tests/unit/features/versioning/test_unit_versioning_serializers.py
  • api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py
  • api/tests/unit/features/versioning/test_unit_versioning_views.py
  • api/tests/unit/features/workflows/core/test_unit_workflows_models.py
  • docs/docs/deployment-self-hosting/observability/_events-catalogue.md

Comment thread api/features/models.py
Comment on lines +785 to +814
def get_superseded_live_feature_state(self) -> typing.Optional["FeatureState"]:
"""Return the live feature state of this state's lineage (same
environment, feature and segment) that this state supersedes when it
goes live. Only meaningful for v1 versioning; identity overrides have
no versioned lineage.
"""
# A segment override's FeatureSegment row can be recreated with it, so
# match the lineage by its segment, not by the FeatureSegment row.
if self.feature_segment_id is None:
lineage_filter = Q(feature_segment__isnull=True)
else:
lineage_filter = Q(
feature_segment__segment_id=self.feature_segment.segment_id # type: ignore[union-attr]
)

superseded: FeatureState | None = (
FeatureState.objects.filter(
lineage_filter,
environment_id=self.environment_id,
feature_id=self.feature_id,
identity__isnull=True,
version__isnull=False,
live_from__isnull=False,
live_from__lte=timezone.now(),
)
.exclude(id=self.id)
.order_by("-version")
.first()
)
return superseded

@coderabbitai coderabbitai Bot Jul 13, 2026

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 | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant model methods and call sites.
git ls-files api/features/models.py api/core/workflows_services.py | cat

echo '--- model outline ---'
ast-grep outline api/features/models.py --view expanded || true

echo '--- workflows_services outline ---'
ast-grep outline api/core/workflows_services.py --view expanded || true

echo '--- search for __gt__ and get_superseded_live_feature_state ---'
rg -n "def __gt__|get_superseded_live_feature_state|_publish_feature_states|live_from|version" api/features/models.py api/core/workflows_services.py

Repository: Flagsmith/flagsmith

Length of output: 20392


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the relevant slices only.
echo '--- api/features/models.py lines around __gt__ ---'
sed -n '560,640p' api/features/models.py

echo '--- api/features/models.py lines around get_superseded_live_feature_state ---'
sed -n '760,830p' api/features/models.py

echo '--- api/core/workflows_services.py relevant slice ---'
sed -n '1,260p' api/core/workflows_services.py | sed -n '1,220p'

Repository: Flagsmith/flagsmith

Length of output: 12469


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the exact implementation of the comparison and any tests around it.
python3 - <<'PY'
from pathlib import Path
path = Path('api/features/models.py')
text = path.read_text()
for start_marker in ['def __gt__', 'def get_superseded_live_feature_state']:
    idx = text.find(start_marker)
    print(f'=== {start_marker} at {idx} ===')
    if idx != -1:
        start = text.rfind('\n', 0, idx)
        end = text.find('\n    def ', idx + 1)
        if end == -1:
            end = len(text)
        print(text[start+1:end])
PY

echo '--- tests mentioning live_from or superseded state ---'
rg -n "superseded_live_feature_state|live_from.*version|version.*live_from|__gt__" api tests

Repository: Flagsmith/flagsmith

Length of output: 12367


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant unit tests and the helper that consumes this method.
sed -n '880,960p' api/tests/unit/features/test_unit_features_models.py
echo '---'
sed -n '1000,1065p' api/tests/unit/features/test_unit_features_models.py
echo '---'
sed -n '630,670p' api/tests/unit/util/mappers/test_unit_mappers_engine.py
echo '---'
sed -n '40,80p' api/core/workflows_services.py

Repository: Flagsmith/flagsmith

Length of output: 7706


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for any explicit expectation that the "current" live state is the one with latest live_from.
rg -n "latest live_from|most recent live from|superseded live state|get_superseded_live_feature_state|__gt__" api/tests/unit/features api/tests/unit/util/mappers

Repository: Flagsmith/flagsmith

Length of output: 580


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '880,960p' api/tests/unit/features/test_unit_features_models.py
echo '---'
sed -n '1000,1065p' api/tests/unit/features/test_unit_features_models.py
echo '---'
sed -n '630,670p' api/tests/unit/util/mappers/test_unit_mappers_engine.py
echo '---'
sed -n '40,80p' api/core/workflows_services.py
echo '---'
rg -n "latest live_from|most recent live from|superseded live state|get_superseded_live_feature_state|__gt__" api/tests/unit/features api/tests/unit/util/mappers

Repository: Flagsmith/flagsmith

Length of output: 8133


Order superseded states by live_from first get_superseded_live_feature_state() should sort by -live_from, -version; ordering by version alone can pick the wrong live state when commit order and scheduled live times diverge, which makes the successor inherit the wrong bucketing salt.

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.

Somehow this should use get_live_feature_states directly

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "def get_live_feature_states" -A 40 api/features/managers.py

Length of output: 1940


@Zaimwa9, agreed — that's a nice simplification and it also resolves the ordering concern I raised, since get_live_feature_states already encodes the canonical v1 "live" definition (deleted_at__isnull=True, live_from__lte=now, version__isnull=False). Something like:

def get_superseded_live_feature_state(self) -> typing.Optional["FeatureState"]:
    if self.feature_segment_id is None:
        lineage_filter = Q(feature_segment__isnull=True)
    else:
        lineage_filter = Q(
            feature_segment__segment_id=self.feature_segment.segment_id  # type: ignore[union-attr]
        )

    return (
        FeatureState.objects.get_live_feature_states(
            environment=self.environment,
            additional_filters=lineage_filter,
            feature_id=self.feature_id,
            identity__isnull=True,
        )
        .exclude(id=self.id)
        .order_by("-live_from", "-version")
        .first()
    )

This keeps the lineage/segment matching intact while delegating the "what counts as live" logic to the manager (which is already exercised by other call sites like get_environment_default), and orders by live_from first so the most recently gone-live state wins over version alone.


✏️ Learnings added
Learnt from: Zaimwa9
Repo: Flagsmith/flagsmith PR: 7963
File: api/features/models.py:785-814
Timestamp: 2026-07-13T08:18:39.411Z
Learning: In `api/features/models.py`, prefer reusing `FeatureState.objects.get_live_feature_states(environment=..., additional_filters=..., **kwargs)` from `api/features/managers.py` instead of manually duplicating the v1 "live" filter (`live_from__isnull=False, live_from__lte=now, version__isnull=False`) when querying for live feature states, as seen in `get_environment_default()` and `get_superseded_live_feature_state()`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

…ate recreation

Multivariate variant bucketing is salted on the feature state id, so any flow
that recreates a feature state (publishing a new v2 version, editing
multivariate weights) changed the salt and re-bucketed already-enrolled
identities.

Add an mv_hashing_salt field that get_multivariate_feature_state_value seeds on
(falling back to the id), preserved across clone() so recreation keeps the
original seed. The engine mapper feeds the salt through django_id, so edge and
local evaluation stay stable without an engine document schema change.

Also add a BEFORE_CREATE guard that raises when a v2 feature state is recreated
outside clone() (the previous version already had it but the new row carries no
salt), to catch future regressions in tests.

Closes #7913
@gagantrivedi gagantrivedi force-pushed the fix/7913-mv-hashing-salt branch from 71ba4ee to 0822078 Compare July 13, 2026 08:45
@gagantrivedi gagantrivedi force-pushed the fix/7913-mv-hashing-salt branch from 0822078 to a32e197 Compare July 13, 2026 08:59
@Zaimwa9 Zaimwa9 force-pushed the fix/7913-mv-hashing-salt-inheritance branch from 9a426e3 to 003520e Compare July 13, 2026 09:41
@github-actions github-actions Bot added docs Documentation updates fix and removed fix docs Documentation updates labels Jul 13, 2026
Base automatically changed from fix/7913-mv-hashing-salt to main July 14, 2026 08:31
@Zaimwa9

Zaimwa9 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Redundant following #7914

@Zaimwa9 Zaimwa9 closed this Jul 14, 2026
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 fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants