Skip to content

feat: Evaluate flags that depend on other flags - #343

Open
khvn26 wants to merge 3 commits into
mainfrom
feat/dependent-flags
Open

feat: Evaluate flags that depend on other flags#343
khvn26 wants to merge 3 commits into
mainfrom
feat/dependent-flags

Conversation

@khvn26

@khvn26 khvn26 commented Aug 27, 2026

Copy link
Copy Markdown
Member

@khvn26
khvn26 force-pushed the feat/dependent-flags branch from 4e7cf34 to b0b6007 Compare August 27, 2026 18:00
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

File Coverage Missing
All files 100%

Minimum allowed coverage is 100%

Generated by 🐒 cobertura-action against 419d686

@codspeed-hq

codspeed-hq Bot commented Aug 27, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 1 untouched benchmark


Comparing feat/dependent-flags (419d686) with main (b57f3ba)

Open in CodSpeed

@khvn26

khvn26 commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

@themis-blindfold review

@khvn26

khvn26 commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 38 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 52872917-94d9-4e3e-93e2-af65ca858811

📥 Commits

Reviewing files that changed from the base of the PR and between 63716d9 and 419d686.

📒 Files selected for processing (2)
  • flag_engine/segments/evaluator.py
  • tests/engine_tests/engine-test-data
📝 Walkthrough

Walkthrough

The evaluation context now supports optional flag results. Segment evaluation injects a lazy $.flags mapping and resolves dependent flags when conditions access them. _DependencyResolver memoises segment matches and flag results, applies override priority, and reports circular dependencies without raising. JSONPath prefix handling uses a shared constant. The test-data submodule now tracks the test/flag-dependencies branch.

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

Merge Risk: 🟡 Moderate · up to 63716

Dependent-flag evaluation can incorrectly select targeting overrides when flags form a circular dependency, and the test-data reference is not pinned, so builds may change without a repository change. The PR should not merge until cycle handling is made consistently non-matching and the test data is pinned or these risks are explicitly accepted.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Comment thread flag_engine/segments/evaluator.py
Comment thread flag_engine/segments/evaluator.py
Comment thread .gitmodules
path = tests/engine_tests/engine-test-data
url = https://github.com/flagsmith/engine-test-data.git
branch = v3.9.0
branch = test/flag-dependencies

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nit · ⚡ Quick win

Pin the dependency corpus to its release tag. Observed: this switches from v3.9.0 to the mutable test/flag-dependencies branch instead of the release tag required for the dependency cases.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Certainly, as soon as it's released.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Keep this open until the release is available; the mutable branch makes the dependency corpus non-reproducible.

🧠 Learning recorded — lands in .themis/learnings.jsonl via the next digest PR.

@themis-blindfold

Copy link
Copy Markdown

⚖️ Themis review: 🟠 Fix before merge

TL;DR: Dependent flags work for the straightforward cases covered by the shared corpus, and all completed CI checks passed. Two paths need correction before merging: ordinary evaluations now pay resolver setup costs, and cycles can return a default flag while reporting its dependent segment as matched.

Area Score
🎯 Correctness 2/5
🧪 Test coverage 3/5
📐 Code quality 2/5
🚀 Product impact 4/5

🟠 Majors

🧹 Nits

  • .gitmodules — pin the shared test corpus to its release tag rather than a mutable feature branch.
📝 Walkthrough
  • Evaluation context - adds a per-call lazy flags mapping for segment conditions to resolve prerequisite flags.
  • Dependency resolver - resolves overriding segments recursively, memoises segment outcomes, and marks cycles.
  • Result assembly - combines the usual segment pass with lazily resolved prerequisite flags.
  • Shared test corpus - advances to the dependency cases, but configures its source as a feature branch.
🧪 How to verify
  1. Evaluate a context without any $.flags condition and assert no resolver or replacement context is created.
  2. Add a direct prerequisite condition and verify enabled, value, and variant dependencies select the expected override.
  3. Exercise a ↔ b with b enabled by default and a disabled by default; assert neither cyclic segment is reported as matched and neither override is applied.
  4. Run pytest tests/unit/segments/test_segments_evaluator.py tests/engine_tests/test_engine.py with the updated corpus checked out.
  5. Run the benchmark suite against a no-dependency context and compare it with the base branch.
    Automate: add regression tests for the no-dependency allocation path and for cycles with truthy default prerequisites.

Product take: This is a major capability for composing rollouts, but evaluation is a hot path and cycle handling must stay deterministic. Once those paths are fixed, the feature is a solid improvement.

🧭 Assumptions & unverified claims

The cycle reproduction should be rerun with the pinned jsonpath-rfc9535==0.1.6 package; that package was unavailable in the local review environment.

Dependent flags are useful; dependent defaults are less so. · reviewed at 63716d9

@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: 2fe46096-59c6-498b-8776-03a0eaba34c8

📥 Commits

Reviewing files that changed from the base of the PR and between cc74d0a and 63716d9.

📒 Files selected for processing (4)
  • .gitmodules
  • flag_engine/context/types.py
  • flag_engine/segments/evaluator.py
  • tests/engine_tests/engine-test-data

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.

Comment thread .gitmodules
khvn26 added 2 commits August 31, 2026 12:44
Adds support for segments conditioned on another flag's result, via a
`$.flags.<feature name>` condition property, as the evaluation half of
dependent flags.

Segment conditions are already JSONPath, so a dependency needs no new
operator or condition type. What it does need is for the flag to be
resolved by the time the condition reads it, and evaluation resolved
every segment before any flag.

Rather than establish up front which segments depend on which flags,
`$.flags` is a mapping that resolves a flag when a condition first
reads it. Resolving a flag evaluates the segments overriding it, which
in turn resolves whatever flags their conditions read, memoised
throughout. The existing single pass is otherwise untouched: a context
whose segments read no flag never enters the resolver, and pays only
for one empty mapping.

Resolving on read rather than in advance means there is no need to
recognise a dependency in a condition property, and so no need to
reimplement enough of the JSONPath grammar to tell that
`$.flags.a['enabled']` and `$.flags.a.enabled` are the same query.
Every spelling works because resolution is triggered by the read
itself. It also costs only what is read: rule short-circuiting means a
condition that is never evaluated resolves nothing.

Measured against main across the benchmark contexts, interleaved to
cancel drift: +3.7%, from the mapping and a shallow copy of the context
to hold it. Establishing up front that an environment has no
dependencies would remove that residual entirely, but needs a field on
the context to carry it.

A flag whose dependencies form a cycle is not resolvable. It serves its
environment default and reports `ERROR; code=CIRCULAR_DEPENDENCY`, so
that a flag which could not be resolved is distinguishable from one
that was never gated, and only flags in the cycle are reported that
way. Crucially the value it falls back to is never published to
`$.flags`, so no other condition can match on a value that exists only
because the cycle was cut — otherwise a segment gated on a flag that
defaults to enabled would be reported as matched while the flag it
overrides stayed at its default. Cycles are still expected to be
rejected where dependencies are written.

Behaviour is covered by test cases in Flagsmith/engine-test-data#59
rather than by unit tests here, so that every engine is held to it.
REVERT BEFORE MERGE.

Dependent flags behaviour is covered by test cases in
Flagsmith/engine-test-data#59 rather than by unit tests here, so that
every engine is held to it. Those cases aren't in a release yet, so
without this the new code is exercised by nothing and CI's 100%
coverage gate fails.

Once #59 is merged and tagged, this goes back to a semver tag, which
Renovate now tracks as of #337.
@khvn26
khvn26 force-pushed the feat/dependent-flags branch from 63716d9 to 99dea3e Compare August 31, 2026 11:44
@khvn26
khvn26 marked this pull request as ready for review August 31, 2026 11:54
@khvn26
khvn26 requested a review from a team as a code owner August 31, 2026 11:54
@khvn26
khvn26 requested review from matthewelwell and removed request for a team August 31, 2026 11:54

@emyller emyller left a comment

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.

Left a few comments towards improving the architecture. I believe we could achieve a simpler and clearer design overall if this was re-approached, but let me know if you'd rather move forward for now — tests pass.

identity: NotRequired[Optional[IdentityContext]]
segments: NotRequired[Dict[str, SegmentContext[SegmentMetadataT, FeatureMetadataT]]]
features: NotRequired[Dict[str, FeatureContext[FeatureMetadataT]]]
flags: NotRequired[Dict[str, FlagResult[FeatureMetadataT]]]

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.

I wonder if this should belong to a new InEngineEvaluationContext(EvaluationContext) type.

Comment on lines +296 to +305
priority = override_feature_context.get(
"priority",
constants.DEFAULT_PRIORITY,
)
if segment_override is None or priority < override_priority:
segment_override = SegmentOverride(
feature_context=override_feature_context,
segment_name=segment_context["name"],
)
override_priority = priority

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.

note: Feels like we're duplicating code.

segments, segment_overrides = evaluate_segments(context)
flags = evaluate_features(context, segment_overrides)

if (resolver := resolved.__dict__.get("_resolver")) is not None:

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.

    if (resolver := resolved.__dict__.get("_resolver")) is not None:

This hints at a few design issues IMO:

  • Having to resort to __dict__ to peek at the cached property feels like obscure heuristics to detect whether a segment consulted a flag;
  • Accessing a private member from the outside.

Perhaps we could already lose some complexity if _DependencyResolver stops doing building _segment_keys_by_feature_name on initialisation, which would allow for dropping the if statement above for free.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support $.flags context values in segment conditions

3 participants