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.
28 KiB
Flag evaluation engine
The Rust feature flags service evaluates flags using a deterministic, hash-based algorithm. This document covers the full evaluation pipeline: dependency resolution, condition matching, rollout hashing, variant selection, super groups, holdout groups, and experience continuity.
Architecture overview
┌─────────────────────────────────────────────────────────────────┐
│ evaluate_all_feature_flags │
├─────────────────────────────────────────────────────────────────┤
│ 1. Build dependency graph (DAG) from all flags │
│ 2. Filter graph to requested flag_keys (+ transitive deps) │
│ 3. Process experience continuity (hash key overrides) │
│ 4. Fetch person/group properties and cohort memberships │
│ 5. Evaluate flags in topological order (parallel per stage) │
└─────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌───────────┐ ┌────────────┐
│ SHA1 │ │ Property │ │ Dependency │
│ hashing │ │ matching │ │ graph │
│ (rollout │ │ (23 │ │ (pre-built │
│ + vars) │ │ operators)│ │ or DAG) │
└──────────┘ └───────────┘ └────────────┘
Core data model
FeatureFlag
pub struct FeatureFlag {
pub id: FeatureFlagId, // i32
pub team_id: i32,
pub name: Option<String>,
pub key: String,
pub filters: FlagFilters,
pub deleted: bool,
pub active: bool,
pub ensure_experience_continuity: Option<bool>,
pub version: Option<i32>,
pub evaluation_runtime: Option<String>, // "server", "client", or "all"
pub evaluation_tags: Option<Vec<String>>,
pub bucketing_identifier: Option<String>, // "distinct_id" or "device_id"
}
EvaluationMetadata
Pre-computed dependency metadata, built by Django at cache-write time and shipped as a top-level field alongside the flags array in the HyperCache.
pub struct EvaluationMetadata {
pub dependency_stages: Vec<Vec<i32>>, // flag IDs grouped by stage (stage 0 first)
pub flags_with_missing_deps: Vec<i32>, // flag IDs with broken dependencies
pub transitive_deps: HashMap<i32, HashSet<i32>>, // flag ID → transitive dep IDs
}
On the wire (JSON), transitive_deps keys are stringified integers ({"1": [2, 3]}).
Custom serde converts between this and the in-memory HashMap<i32, HashSet<i32>>.
FlagFilters
pub struct FlagFilters {
pub groups: Vec<FlagPropertyGroup>, // condition sets (OR'd together)
pub multivariate: Option<MultivariateFlagOptions>, // variant definitions
pub aggregation_group_type_index: Option<i32>, // None=person, 0=project, 1=org, etc.
pub payloads: Option<serde_json::Value>, // variant key -> payload map
pub super_groups: Option<Vec<FlagPropertyGroup>>, // early access feature gate
pub holdout_groups: Option<Vec<FlagPropertyGroup>>, // holdout/control conditions
}
FlagPropertyGroup (a single condition set)
pub struct FlagPropertyGroup {
pub properties: Option<Vec<PropertyFilter>>, // filters (AND'd together)
pub rollout_percentage: Option<f64>, // 0.0-100.0, defaults to 100.0
pub variant: Option<String>, // variant override for this condition
}
PropertyFilter
pub struct PropertyFilter {
pub key: String,
pub value: Option<serde_json::Value>,
pub operator: Option<OperatorType>,
pub prop_type: PropertyType, // Person, Group, Cohort, or Flag
pub negation: Option<bool>,
pub group_type_index: Option<i32>,
#[serde(skip)]
pub compiled_regex: Option<CompiledRegex>, // Pre-compiled regex (see below)
}
CompiledRegex
Pre-compiled regex state for Regex/NotRegex operators, populated by prepare_regex() at flag-load time. Skipped during serde (de)serialization.
pub enum CompiledRegex {
Compiled(fancy_regex::Regex), // Valid pattern, compiled with backtrack_limit(10_000)
InvalidPattern, // Pattern failed to compile — always returns Ok(false)
}
fancy_regex::Regex uses Arc<Prog> internally, so Clone is cheap and the enum is Send + Sync for concurrent evaluation across tokio tasks.
Regex pre-compilation
Regex patterns are compiled once per request rather than on every match_property() call.
Entry point: FeatureFlagList::prepare_regexes() is called in fetch_and_filter() (in handler/flags.rs) immediately after flag list construction, before any evaluation begins.
PropertyFilter::prepare_regex()— compiles the filter's value as a regex withbacktrack_limit(10_000)forRegex/NotRegexoperators. No-op for other operators or whencompiled_regexis alreadySome(idempotent). StoresCompiledRegex::Compiledon success orCompiledRegex::InvalidPatternon failure. IfvalueisNone, leavescompiled_regexasNone(fallback path).FeatureFlagList::prepare_regexes()— walks all flags →filters.groupsandfilters.super_groups→ property filters, callingprepare_regex()on each.
The fallback on-the-fly compilation path (compiled_regex: None) is retained for cohort property filters (constructed dynamically in cohort_operations.rs, not from the flag cache) and for test code that constructs PropertyFilter directly.
Per-flag evaluation flow
The get_match function in rust/feature-flags/src/flags/flag_matching.rs evaluates a single flag:
┌────────────────────────────┐
│ Flag active? │──── No ──▶ false (FlagDisabled)
└────────────────────────────┘
│ Yes
▼
┌────────────────────────────┐
│ Resolve hashed_identifier │
│ (group key, device_id, │
│ hash override, or │
│ distinct_id) │
└────────────────────────────┘
│
▼
┌────────────────────────────┐
│ Group flag with empty │──── Yes ──▶ false (NoGroupType)
│ group key? │
└────────────────────────────┘
│ No
▼
┌────────────────────────────┐
│ Evaluate super_groups │──── Match ──▶ return result (SuperConditionValue)
│ (early access gate) │
└────────────────────────────┘
│ No match / not applicable
▼
┌────────────────────────────┐
│ Evaluate holdout_groups │──── In holdout ──▶ true + holdout variant
│ (holdout check) │ (HoldoutConditionValue)
└────────────────────────────┘
│ Not in holdout
▼
┌────────────────────────────┐
│ Iterate condition groups │
│ (OR logic - first match │
│ wins) │
│ │
│ For each group: │
│ 1. Check flag-value │
│ filters │
│ 2. Check property │
│ filters (AND logic) │
│ 3. Check cohort filters │
│ 4. Rollout hash check │
└────────────────────────────┘
│
┌───────┴────────┐
▼ ▼
Matched No match
┌──────────┐ ┌──────────┐
│ Resolve │ │ Return │
│ variant │ │ false │
│ + payload│ │ (highest │
└──────────┘ │ reason) │
└──────────┘
Hash-based rollout
Flag rollout uses SHA1 hashing to deterministically assign users to buckets.
Hash calculation
// rust/feature-flags/src/flags/flag_matching_utils.rs
pub fn calculate_hash(prefix: &str, hashed_identifier: &str, salt: &str) -> f64 {
let hash_key = format!("{prefix}{hashed_identifier}{salt}");
let hash_value = Sha1::digest(hash_key.as_bytes());
let hash_val: u64 = u64::from_be_bytes(hash_value[..8].try_into().unwrap()) >> 4;
hash_val as f64 / LONG_SCALE as f64 // LONG_SCALE = 0xfffffffffffffff
}
The hash produces a deterministic float in [0, 1) from SHA1("{flag_key}.{identifier}{salt}").
Rollout check
hash = SHA1("{flag_key}.{identifier}") → float [0, 1)
if hash <= rollout_percentage / 100.0 → user is IN the rollout
if hash > rollout_percentage / 100.0 → user is OUT (OutOfRolloutBound)
A 100% rollout skips the hash calculation entirely.
Identifier resolution priority
The identifier used for hashing depends on the flag configuration:
| Flag type | Bucketing | Identifier (in priority order) |
|---|---|---|
| Group flag | N/A | Group key from groups map |
| Person flag | device_id |
$device_id from request, fallback to distinct_id |
| Person flag | distinct_id |
DB hash_key_override > request $anon_distinct_id > distinct_id |
Condition matching
Each flag has one or more condition groups (OR'd). Within each group, property filters are AND'd.
Condition evaluation order
- Flag-value filters (
prop_type: Flag): Check dependent flag results first. If any fail, the condition fails immediately. - Non-cohort property filters (
prop_type: PersonorGroup): Checked next (cheaper than cohort lookups). - Cohort filters (
prop_type: Cohort): Checked last (may require DB lookups for static cohorts or recursive property evaluation for dynamic cohorts). - Rollout hash check: Only performed if all filters pass.
Property operators
Defined in rust/feature-flags/src/properties/property_matching.rs. The service supports 23 operators:
| Category | Operators | Behavior |
|---|---|---|
| Existence | is_set, is_not_set |
Key presence check in property map |
| Equality | exact, is_not |
Case-insensitive comparison. Arrays checked with contains. Boolean normalization for "true"/"false" strings. |
| String | icontains, not_icontains |
ASCII-case-insensitive substring match |
| Regex | regex, not_regex |
fancy_regex with 10,000 step backtrack limit (ReDoS protection). Patterns are pre-compiled once per request via prepare_regexes(). Three-state dispatch in match_property(): pre-compiled fast path → InvalidPattern short-circuit to Ok(false) → fallback on-the-fly compilation. |
| Numeric | gt, gte, lt, lte |
Parse both sides as f64 |
| Semver | semver_gt, semver_gte, semver_lt, semver_lte, semver_eq, semver_neq |
Direct Version comparison |
| Semver range | semver_tilde, semver_caret, semver_wildcard |
VersionReq parsing (~1.2.3, ^1.2.3, 1.2.x) |
| Date | is_date_exact, is_date_after, is_date_before |
Supports relative dates, ISO 8601, Unix timestamps |
| Cohort | in, not_in |
Handled by cohort matching, not property matching |
| Flag | flag_evaluates_to |
Handled by flag dependency matching |
Multivariate flags (variant selection)
Multivariate flags define multiple variants with rollout percentages that must sum to 100%.
Variant hash
Variant selection uses a separate hash from rollout, with salt "variant":
hash = SHA1("{flag_key}.{identifier}variant") → float [0, 1)
Variants are walked in order with cumulative percentages:
Variants: [A: 33%, B: 33%, C: 34%]
hash < 0.33 → variant A
hash < 0.66 → variant B
hash < 1.00 → variant C
Variant overrides
A condition group can specify a variant field that overrides the computed variant when that condition matches. This allows targeting specific user segments with specific variants.
Payloads
Each variant (or "true" for boolean flags) can have a JSON payload stored in filters.payloads. The payload is included in the evaluation result.
Super groups (early access features)
Super groups act as a gate for early access enrollment. Defined in filters.super_groups.
Evaluation
- Only the first super group is evaluated
- Checks if the person has any property mentioned in the super condition (typically
$feature_enrollment/{flag_key}) - If the person has the property, the super condition is evaluated and its result is returned immediately (reason:
SuperConditionValue) - If the person does not have the property, evaluation falls through to normal conditions
Super groups take the highest priority in match reasons (score: 6).
Holdout groups
Holdout groups exclude users from experiments to serve as a baseline. Defined in filters.holdout_groups.
Evaluation
- Only the first holdout group is evaluated
- Uses a separate hash prefix
"holdout-"(not the flag key), so holdout assignment is independent of individual flag rollout - If the user's hash falls within the holdout percentage, they are in the holdout and the flag returns
truewith a holdout variant (default:"holdout") - If the user is outside the holdout, normal condition evaluation proceeds
Holdout evaluation happens after super groups but before normal conditions.
Flag dependencies
Flags can depend on other flags via PropertyFilter with prop_type: Flag and operator: flag_evaluates_to.
Dependency graph
The dependency graph determines evaluation order so that flags are evaluated after their dependencies.
Pre-computed path (HyperCache)
Django pre-computes all dependency metadata at cache-write time and ships it as a top-level evaluation_metadata alongside the flags array in the HyperCache:
{
"flags": [...],
"evaluation_metadata": {
"dependency_stages": [[3], [2], [1]],
"flags_with_missing_deps": [5],
"transitive_deps": {"1": [2, 3], "2": [3]}
}
}
dependency_stages: Flag IDs pre-grouped by evaluation stage. Stage 0 (no deps) first.flags_with_missing_deps: Flag IDs with missing, cyclic, or transitively broken dependencies (fail closed).transitive_deps: Flag ID → transitive dependency flag IDs. Keys are stringified ints (JSON requirement).
Rust deserializes EvaluationMetadata and maps pre-grouped stages directly to Vec<Vec<FeatureFlag>> — no graph construction or Kahn's algorithm needed.
Fallback path (PostgreSQL)
When evaluation_metadata is absent (PG fallback, old cache entries), the service builds a DAG using petgraph:
- Extract dependencies from all flag property filters
- Build a directed graph (edges from dependent -> dependency)
- Detect and remove cycles (cycle-starting nodes and all their dependents are removed)
- Track missing dependencies (flags depending on non-existent flags)
- Compute topological evaluation stages using Kahn's algorithm
Backwards compatibility
The two paths are fully compatible via #[serde(default)] on evaluation_metadata:
- Old Rust + new cache:
evaluation_metadatais an unknown field, ignored. Falls back to petgraph. - New Rust + old cache:
evaluation_metadataabsent →None→ falls back to petgraph. - New Rust + new cache:
evaluation_metadatapresent → fast pre-computed path.
Evaluation stages
Flags are evaluated in batched stages. Each stage contains flags whose dependencies are all resolved:
Stage 0: [flag_A, flag_B] ← no dependencies
Stage 1: [flag_C, flag_D] ← depend on flags in stage 0
Stage 2: [flag_E] ← depends on flags in stage 1
Flags within a stage are evaluated in parallel using Rayon (par_iter).
Flag value matching
// How flag dependency filters are resolved:
match filter.value {
true => flag_value != Boolean(false) // "truthy" -- any non-false value
false => flag_value == Boolean(false) // "falsy"
String(s) => flag_value == String(s) // exact variant match
}
Evaluated results are cached in FlagEvaluationState.flag_evaluation_results for subsequent dependent flags. Flags with missing or cyclic dependencies evaluate to false with reason MissingDependency.
Partial flag evaluation
When flag_keys is provided in the request, the dependency graph is filtered to include only the requested flags and their transitive dependencies. This avoids evaluating unrelated flags.
Experience continuity
Experience continuity ensures users see the same flag value even when their distinct_id changes (e.g., anonymous user logs in). It applies to person-based flags with ensure_experience_continuity enabled and distinct_id bucketing. See experience-continuity.md for the full design, including the hash key override flow and optimization for 100%-rollout flags.
Cohort matching
Dynamic cohorts
Dynamic cohorts define membership via property filters. The service resolves them by:
- Fetching cohort definitions (from moka in-memory cache, backed by PostgreSQL)
- Building a dependency graph for nested cohorts (cohorts can reference other cohorts)
- Evaluating cohort property filters against person/group properties
Static cohorts
Static cohorts have pre-computed membership lists in the insights_cohortpeople table. The service uses a batched query with unnest to check membership for multiple cohorts at once:
WITH cohort_membership AS (
SELECT c.cohort_id,
CASE WHEN pc.cohort_id IS NOT NULL THEN true ELSE false END AS is_member
FROM unnest($1::integer[]) AS c(cohort_id)
LEFT JOIN insights_cohortpeople AS pc
ON pc.person_id = $2 AND pc.cohort_id = c.cohort_id
)
SELECT cohort_id, is_member FROM cohort_membership
Cohort caching
Cohort definitions are cached in-memory using moka:
| Parameter | Default | Purpose |
|---|---|---|
| Capacity | 256 MB | Memory-based eviction |
| TTL | 5 minutes | Time-based expiration |
| Thundering herd | try_get_with |
Per-key coalescing |
| Error caching | Disabled | Failed fetches are retried immediately |
Match reasons
Each evaluation result includes a reason explaining why the flag matched or didn't match:
| Reason | Score | Meaning |
|---|---|---|
SuperConditionValue |
6 | Matched via super group (early access) |
HoldoutConditionValue |
5 | In holdout group |
ConditionMatch |
4 | Matched a condition group + rollout |
NoGroupType |
3 | Group flag but no group key provided |
OutOfRolloutBound |
2 | Conditions matched but outside rollout percentage |
NoConditionMatch |
1 | No condition group matched |
FlagDisabled |
0 | Flag is not active |
MissingDependency |
-1 | A required dependency flag was not found |
When multiple conditions are checked, the highest-priority reason is returned even when no condition ultimately matches (e.g., OutOfRolloutBound is more informative than NoConditionMatch).
Per-request state
The FlagEvaluationState struct caches all data needed for a single request, avoiding redundant DB lookups when evaluating multiple flags:
pub struct FlagEvaluationState {
person_id: Option<PersonId>,
person_properties: Option<HashMap<String, Value>>,
group_properties: HashMap<GroupTypeIndex, HashMap<String, Value>>,
cohorts: Option<Vec<Cohort>>,
static_cohort_matches: Option<HashMap<CohortId, bool>>,
flag_evaluation_results: HashMap<FeatureFlagId, FlagValue>,
}
Property overrides from the request body are merged on top of DB-fetched properties. Request overrides take precedence.
Data fetching strategy
The evaluation engine follows a lazy-but-batched approach:
- Flag definitions: Fetched once per request from HyperCache (Redis -> S3 -> PostgreSQL), including pre-computed
evaluation_metadatawhen available - Group type mappings: Fetched once per request if any flag uses groups
- Person properties: Fetched once per request from PostgreSQL, merged with request overrides
- Group properties: Fetched once per request from PostgreSQL, merged with request overrides
- Cohort definitions: Fetched from moka cache (backed by PostgreSQL)
- Static cohort memberships: Fetched once per request via batched query
- Hash key overrides: Fetched once per request if any flag uses experience continuity
- Flag evaluation results: Accumulated during evaluation, used for flag-on-flag dependencies
Related files
| File | Purpose |
|---|---|
rust/feature-flags/src/handler/evaluation.rs |
Entry point: creates matcher and calls evaluate |
rust/feature-flags/src/flags/flag_matching.rs |
Core matching engine: FeatureFlagMatcher |
rust/feature-flags/src/flags/flag_matching_utils.rs |
Hash calculation, property fetching, DB queries |
rust/feature-flags/src/properties/property_matching.rs |
Property filter operator implementations |
rust/feature-flags/src/flags/flag_models.rs |
Data models |
rust/feature-flags/src/flags/flag_operations.rs |
Flag helper methods, DependencyProvider trait |
rust/feature-flags/src/flags/flag_match_reason.rs |
Match reason enum with priority ordering |
rust/feature-flags/src/flags/property_filter.rs |
Regex pre-compilation: prepare_regex() implementation |
rust/feature-flags/src/utils/graph_utils.rs |
Dependency graph (pre-computed + petgraph fallback) |
rust/feature-flags/src/cohorts/cohort_cache_manager.rs |
Moka-backed cohort cache |
rust/feature-flags/src/flags/test_flag_matching.rs |
Unit tests for flag matching |
rust/feature-flags/tests/test_flag_matching_consistency.rs |
Cross-language consistency tests |
See also
- Rust service overview - Service architecture, endpoints, configuration
- Experience continuity - Hash key overrides for consistent flag values
- Database interaction patterns - PostgreSQL connection pooling and query routing
- HyperCache system - Multi-tier caching