diff --git a/libdd-ffe-test-suite/Cargo.toml b/libdd-ffe-test-suite/Cargo.toml index e28ab8c448..e8b4a2f5bf 100644 --- a/libdd-ffe-test-suite/Cargo.toml +++ b/libdd-ffe-test-suite/Cargo.toml @@ -10,6 +10,11 @@ publish = false [lib] bench = false +[[bench]] +name = "dependent-flags" +harness = false +path = "benches/dependent_flags.rs" + [dev-dependencies] libdd-ffe = { path = "../libdd-ffe", features = ["flagevaluation-evp"] } chrono = { workspace = true, features = ["now", "serde"] } diff --git a/libdd-ffe-test-suite/benches/dependent_flags.rs b/libdd-ffe-test-suite/benches/dependent_flags.rs new file mode 100644 index 0000000000..7dc1dbf7b1 --- /dev/null +++ b/libdd-ffe-test-suite/benches/dependent_flags.rs @@ -0,0 +1,53 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use criterion::{Criterion, criterion_group, criterion_main}; +use libdd_ffe::rules_based::{ + Configuration, EvaluationContext, ExpectedFlagType, UniversalFlagConfig, +}; +use serde_json::json; +use std::hint::black_box; + +fn bench_dependencies(c: &mut Criterion) { + let parent = json!({"key":"parent","enabled":true,"variationType":"BOOLEAN", + "variations":{"on":{"key":"on","value":true}}, + "allocations":[{"key":"default","splits":[{"shards":[],"variationKey":"on"}]}]}); + for (name, repeated) in [ + ("legacy", 0), + ("one_prerequisite", 1), + ("memoized_16_references", 16), + ] { + let mut root = parent.clone(); + root["key"] = json!("root"); + if repeated > 0 { + root["allocations"][0]["rules"] = json!([{"conditions": (0..repeated).map(|_| + json!({"flagEvaluation":{"key":"parent"},"operator":"ONE_OF","value":["on"]}) + ).collect::>()}]); + } + let config = Configuration::from_server_response( + UniversalFlagConfig::from_json( + serde_json::to_vec( + &json!({"createdAt":"2026-10-28T00:00:00Z","environment":{"name":"test"}, + "flags":{"root":root,"parent":parent}}), + ) + .unwrap(), + ) + .unwrap(), + ); + let context = EvaluationContext::new(Some("subject".into()), Default::default()); + let now = chrono::Utc::now(); + c.bench_function(&format!("dependent_flags/{name}"), |b| { + b.iter(|| { + black_box(config.eval_flag( + black_box("root"), + black_box(&context), + ExpectedFlagType::Boolean, + now, + )) + .unwrap() + }) + }); + } +} +criterion_group!(benches, bench_dependencies); +criterion_main!(benches); diff --git a/libdd-ffe/README.md b/libdd-ffe/README.md index 288e369279..4c4a63f1f6 100644 --- a/libdd-ffe/README.md +++ b/libdd-ffe/README.md @@ -113,3 +113,30 @@ This library change does not enable PHP EVP emission or change flag evaluation results. PHP capture/admission/lifecycle integration and its end-to-end release validation are separate work. Native matched-build IPC/HTTP tests are not a substitute for PHP upgrade tests or Windows CI. + +## Dependent flags + +Rules may mix ordinary attribute conditions with `flagEvaluation` references. A reference +compares the prerequisite's selected **variant key**, using `ONE_OF` or `NOT_ONE_OF` with a +string array, including an empty array. References contain only a nonempty `key` and cannot +also specify an `attribute`. Conditions run in authored order and short-circuit. A mismatch +rejects the rule; a reached missing, disabled, malformed, or unassigned prerequisite fails +the root with `DependencyEvaluationFailed`, preserving its cause. + +Each root evaluation uses one configuration, context, timestamp, and experimentation opt-out +policy. Completed prerequisites are memoized only within that invocation. Cycles fail; +`evaluatorParams.maxDependencyDepth` limits dependency edges (default 10, accepted range +0–255). The limit is validated only when a dependency condition executes. Invalid values, +including explicit `null`, fail that evaluation with `ConfigurationParseError` without +preventing configuration loading, independent evaluations, or skipped dependency conditions. +Cached results retain the deepest dependency visited, including rejected rules, so reuse +from a deeper path cannot bypass the limit. + +`Configuration::eval_flag_with_trace` additionally returns unique prerequisite evaluation +records, including prerequisites rejected by the depth limit and excluding the requested root. +SDKs can log these evaluations even if a later condition rejects their rule. Only records +marked `exposure_eligible` belong to the root's successful rule/split path; a failed root has +no eligible prerequisite exposures. SDKs must +also apply each assignment's `do_log`, consent, and ordinary exposure deduplication policy. +The trace does not emit telemetry itself. SDKs should advertise dependent-flag support only +after integrating and testing both evaluation and exposure reporting. diff --git a/libdd-ffe/src/rules_based/error.rs b/libdd-ffe/src/rules_based/error.rs index 38538a460f..0d865c3218 100644 --- a/libdd-ffe/src/rules_based/error.rs +++ b/libdd-ffe/src/rules_based/error.rs @@ -14,6 +14,22 @@ use crate::rules_based::{ExpectedFlagType, FlagType, Str}; #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[non_exhaustive] pub enum EvaluationError { + /// A prerequisite failed. Its cause is preserved, while SDKs can distinguish this from + /// a directly requested flag's normal disabled/default result. + #[error("prerequisite {flag_key} failed: {source}")] + DependencyEvaluationFailed { + /// Referenced prerequisite key. + flag_key: Str, + /// Original prerequisite failure. + source: Box, + }, + /// A reached prerequisite references a flag already being evaluated. + #[error("flag dependency cycle")] + DependencyCycle, + + /// A reached prerequisite exceeds the configuration's dependency depth limit. + #[error("flag dependency depth exceeded")] + DependencyDepthExceeded, /// Requested flag has unexpected type. #[error("invalid flag type (expected: {expected:?}, found: {found:?})")] TypeMismatch { diff --git a/libdd-ffe/src/rules_based/eval/dependent_flags_tests.rs b/libdd-ffe/src/rules_based/eval/dependent_flags_tests.rs new file mode 100644 index 0000000000..23fc808ad5 --- /dev/null +++ b/libdd-ffe/src/rules_based/eval/dependent_flags_tests.rs @@ -0,0 +1,689 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use super::eval_assignment::*; +use crate::rules_based::{ + Configuration, EvaluationContext, EvaluationError, ExpectedFlagType, UniversalFlagConfig, +}; +use chrono::{DateTime, Utc}; +use serde_json::{Value, json}; +use std::{collections::HashMap, sync::Arc}; + +fn allocation(conditions: Vec, variant: &str) -> Value { + json!({"key": variant, "rules": [{"conditions": conditions}], "doLog": true, + "splits": [{"shards": [], "variationKey": variant}]}) +} + +fn flag(conditions: Vec) -> Value { + json!({"key": "ignored", "enabled": true, "variationType": "BOOLEAN", + "variations": {"on": {"key": "on", "value": false}, "off": {"key": "off", "value": true}}, + "allocations": [allocation(conditions, "on"), allocation(vec![], "off")]}) +} + +fn dependency(key: &str) -> Value { + json!({"flagEvaluation": {"key": key}, "operator": "ONE_OF", "value": ["on"]}) +} + +fn fails() -> Value { + json!({"attribute": "country", "operator": "ONE_OF", "value": ["FR"]}) +} + +fn wire(flags: Value) -> Value { + json!({"createdAt": "2026-10-28T00:00:00Z", "environment": {"name": "test"}, "flags": flags}) +} + +fn eval(wire: Value) -> EvaluationResult { + evaluate( + wire, + EvaluationOptions::default(), + EvaluationContext::new(Some("subject".into()), Default::default()), + ) +} + +fn evaluate( + wire: Value, + options: EvaluationOptions, + context: EvaluationContext, +) -> EvaluationResult { + let config = Configuration::from_server_response( + UniversalFlagConfig::from_json(serde_json::to_vec(&wire).unwrap()).unwrap(), + ); + config.eval_flag_with_trace( + "root", + &context, + ExpectedFlagType::Boolean, + "2026-10-28T00:00:00Z".parse::>().unwrap(), + options, + ) +} + +fn cause(error: &EvaluationError) -> &EvaluationError { + match error { + EvaluationError::DependencyEvaluationFailed { source, .. } => cause(source), + error => error, + } +} + +#[test] +fn compares_variant_keys_not_values_and_supports_both_membership_operators() { + for (operator, values, expected) in [ + ("ONE_OF", json!(["on"]), "on"), + ("ONE_OF", json!(["false"]), "off"), + ("NOT_ONE_OF", json!(["off"]), "on"), + ("NOT_ONE_OF", json!(["on"]), "off"), + ("ONE_OF", json!([]), "off"), + ("NOT_ONE_OF", json!([]), "on"), + ] { + let condition = + json!({"flagEvaluation": {"key": "parent"}, "operator": operator, "value": values}); + let result = eval(wire( + json!({"root": flag(vec![condition]), "parent": flag(vec![])}), + )); + assert_eq!(result.result.unwrap().variation_key.as_str(), expected); + assert_eq!(result.evaluations.len(), 1); + assert_eq!(result.evaluations[0].exposure_eligible, expected == "on"); + } +} + +#[test] +fn empty_dependency_comparands_still_evaluate_the_prerequisite() { + for operator in ["ONE_OF", "NOT_ONE_OF"] { + let condition = + json!({"flagEvaluation": {"key": "missing"}, "operator": operator, "value": []}); + let result = eval(wire(json!({"root": flag(vec![condition])}))); + assert_eq!( + cause(result.result.as_ref().unwrap_err()), + &EvaluationError::FlagUnrecognizedOrDisabled + ); + assert_eq!(result.evaluations.len(), 1); + assert_eq!(result.evaluations[0].flag_key.as_str(), "missing"); + assert!(!result.evaluations[0].exposure_eligible); + } +} + +#[test] +fn authored_order_short_circuits_missing_prerequisites() { + let result = eval(wire( + json!({"root": flag(vec![fails(), dependency("missing")])}), + )); + assert_eq!(result.result.unwrap().variation_key.as_str(), "off"); + assert!(result.evaluations.is_empty()); + let result = eval(wire( + json!({"root": flag(vec![dependency("missing"), fails()])}), + )); + assert!(matches!( + result.result, + Err(EvaluationError::DependencyEvaluationFailed { .. }) + )); + assert_eq!( + cause(result.result.as_ref().unwrap_err()), + &EvaluationError::FlagUnrecognizedOrDisabled + ); + assert_eq!(result.evaluations.len(), 1); +} + +#[test] +fn reached_failed_prerequisites_abort_the_root_instead_of_selecting_fallback() { + let mut disabled = flag(vec![]); + disabled["enabled"] = json!(false); + let mut unassigned = flag(vec![]); + unassigned["allocations"] = json!([]); + for (parent, expected) in [ + (disabled, EvaluationError::FlagDisabled), + (unassigned, EvaluationError::DefaultAllocationNull), + (json!({}), EvaluationError::FlagConfigurationInvalid), + ] { + let result = eval(wire( + json!({"root": flag(vec![dependency("parent")]), "parent": parent}), + )); + assert_eq!(cause(result.result.as_ref().unwrap_err()), &expected); + assert_eq!( + result.evaluations[0].result.as_ref().unwrap_err(), + &expected + ); + assert!(!result.evaluations[0].exposure_eligible); + } +} + +#[test] +fn invalid_dependency_conditions_are_isolated_to_the_containing_flag() { + for invalid in [ + json!({"flagEvaluation": {"key": "parent"}, "attribute": "country", "operator": "ONE_OF", "value": ["on"]}), + json!({"flagEvaluation": {"key": "parent"}, "attribute": "", "operator": "ONE_OF", "value": ["on"]}), + json!({"flagEvaluation": {"key": "parent"}, "attribute": null, "operator": "ONE_OF", "value": ["on"]}), + json!({"flagEvaluation": {"key": "parent", "extra": true}, "operator": "ONE_OF", "value": ["on"]}), + json!({"flagEvaluation": null, "attribute": "country", "operator": "ONE_OF", "value": ["FR"]}), + json!({"flagEvaluation": null, "operator": "ONE_OF", "value": ["on"]}), + json!({"flagEvaluation": {"key": ""}, "operator": "ONE_OF", "value": ["on"]}), + json!({"flagEvaluation": {"key": "parent"}, "operator": "IS_NULL", "value": false}), + json!({"flagEvaluation": {"key": "parent"}, "operator": "ONE_OF", "value": "on"}), + json!({"flagEvaluation": {"key": "parent"}, "operator": "ONE_OF", "value": [true]}), + json!({"attribute": null, "operator": "ONE_OF", "value": ["on"]}), + ] { + let w = wire(json!({"root": flag(vec![invalid]), "parent": flag(vec![])})); + assert_eq!( + eval(w.clone()).result.unwrap_err(), + EvaluationError::FlagConfigurationInvalid + ); + let mut dependent = w.clone(); + dependent["flags"]["bad"] = dependent["flags"]["root"].clone(); + dependent["flags"]["root"] = flag(vec![dependency("bad")]); + let result = eval(dependent); + assert_eq!( + cause(result.result.as_ref().unwrap_err()), + &EvaluationError::FlagConfigurationInvalid + ); + assert_eq!(result.evaluations.len(), 1); + assert_eq!(result.evaluations[0].flag_key.as_str(), "bad"); + assert!(!result.evaluations[0].exposure_eligible); + let config = Configuration::from_server_response( + UniversalFlagConfig::from_json(serde_json::to_vec(&w).unwrap()).unwrap(), + ); + assert!( + config + .eval_flag( + "parent", + &EvaluationContext::new(None, Default::default()), + ExpectedFlagType::Any, + Utc::now() + ) + .is_ok() + ); + } +} + +#[test] +fn an_empty_attribute_selector_without_a_reference_remains_valid() { + let condition = json!({"attribute": "", "operator": "IS_NULL", "value": true}); + let result = eval(wire(json!({"root": flag(vec![condition])}))); + assert_eq!(result.result.unwrap().variation_key.as_str(), "on"); + assert!(result.evaluations.is_empty()); +} + +#[test] +fn dnf_expansion_memoizes_but_restores_exposure_eligibility_for_the_winning_rule() { + let mut root = flag(vec![]); + root["allocations"][0]["rules"] = json!([ + {"conditions": [dependency("parent"), fails()]}, + {"conditions": [dependency("parent")]} + ]); + let result = eval(wire(json!({"root": root, "parent": flag(vec![])}))); + assert_eq!(result.result.unwrap().variation_key.as_str(), "on"); + assert_eq!(result.evaluations.len(), 1); + assert!(result.evaluations[0].exposure_eligible); +} + +#[test] +fn rejected_rules_retain_evaluations_without_exposures() { + let result = eval(wire( + json!({"root": flag(vec![dependency("parent"), fails()]), "parent": flag(vec![])}), + )); + assert_eq!(result.result.unwrap().variation_key.as_str(), "off"); + assert_eq!(result.evaluations.len(), 1); + assert!(result.evaluations[0].result.is_ok()); + assert!(!result.evaluations[0].exposure_eligible); +} + +#[test] +fn rejected_split_does_not_expose_prerequisites() { + let mut root = flag(vec![dependency("parent")]); + root["allocations"][0]["splits"][0]["shards"] = + json!([{"salt":"salt", "totalShards":100,"ranges":[]}]); + let result = eval(wire(json!({"root": root, "parent": flag(vec![])}))); + assert_eq!(result.result.unwrap().variation_key.as_str(), "off"); + assert!(!result.evaluations[0].exposure_eligible); +} + +#[test] +fn root_failure_suppresses_all_prerequisite_exposures() { + let result = eval(wire( + json!({"root": flag(vec![dependency("parent"), dependency("missing")]), "parent": flag(vec![])}), + )); + assert!(result.result.is_err()); + assert_eq!(result.evaluations.len(), 2); + assert!(result.evaluations.iter().all(|r| !r.exposure_eligible)); +} + +#[test] +fn shared_transitive_dependencies_are_reported_once_and_keep_do_log() { + let mut parent = flag(vec![dependency("leaf")]); + parent["allocations"][0]["doLog"] = json!(false); + let result = eval(wire( + json!({"root": flag(vec![dependency("a"), dependency("b")]), + "a": parent, "b": flag(vec![dependency("leaf")]), "leaf": flag(vec![])}), + )); + assert!(result.result.is_ok()); + assert_eq!( + result + .evaluations + .iter() + .map(|r| r.flag_key.as_str()) + .collect::>(), + ["leaf", "a", "b"] + ); + assert!(result.evaluations.iter().all(|r| r.exposure_eligible)); + assert!(!result.evaluations[1].result.as_ref().unwrap().do_log); +} + +#[test] +fn rejected_transitive_paths_never_gain_exposure_eligibility() { + let result = eval(wire(json!({"root": flag(vec![dependency("a")]), + "a": flag(vec![dependency("leaf"), fails()]), "leaf": flag(vec![])}))); + assert_eq!(result.result.unwrap().variation_key.as_str(), "off"); + assert!(result.evaluations.iter().all(|r| !r.exposure_eligible)); +} + +#[test] +fn cycles_fail_without_evaluating_the_root_twice() { + for flags in [ + json!({"root": flag(vec![dependency("root")])}), + json!({"root": flag(vec![dependency("parent")]), "parent": flag(vec![dependency("root")])}), + ] { + let result = eval(wire(flags)); + assert_eq!( + cause(result.result.as_ref().unwrap_err()), + &EvaluationError::DependencyCycle + ); + assert!( + result + .evaluations + .iter() + .all(|r| r.flag_key.as_str() != "root" && !r.exposure_eligible) + ); + } +} + +fn chain(edges: usize) -> Value { + let mut flags = serde_json::Map::new(); + for n in 0..=edges { + let key = if n == 0 { + "root".to_owned() + } else { + format!("f{n}") + }; + flags.insert( + key, + flag(if n < edges { + vec![dependency(&format!("f{}", n + 1))] + } else { + vec![] + }), + ); + } + wire(flags.into()) +} + +#[test] +fn depth_counts_edges_defaults_to_ten_and_preserves_explicit_zero() { + for (edges, limit, passes) in [ + (0, Some(0), true), + (1, Some(0), false), + (1, Some(1), true), + (2, Some(1), false), + (10, None, true), + (11, None, false), + (255, Some(255), true), + (256, Some(255), false), + ] { + let mut w = chain(edges); + if let Some(limit) = limit { + w["evaluatorParams"] = json!({"maxDependencyDepth": limit}); + } + let result = eval(w); + assert_eq!( + result.result.is_ok(), + passes, + "edges={edges} limit={limit:?}" + ); + if !passes { + assert_eq!( + cause(result.result.as_ref().unwrap_err()), + &EvaluationError::DependencyDepthExceeded + ); + } + } +} + +#[test] +fn invalid_depth_limits_only_fail_when_a_dependency_executes() { + for limit in [ + json!(-1), + json!(256), + json!(1.5), + json!("10"), + json!(true), + json!(false), + json!(null), + json!([]), + json!({}), + ] { + let mut w = wire(json!({"root": flag(vec![]), + "dependent": flag(vec![dependency("parent")]), "parent": flag(vec![])})); + w["evaluatorParams"] = json!({"maxDependencyDepth": limit}); + let bytes = serde_json::to_vec(&w).unwrap(); + let parsed = UniversalFlagConfig::from_json(bytes.clone()).unwrap(); + assert_eq!(parsed.to_json(), bytes); + let config = Configuration::from_server_response(parsed); + let context = EvaluationContext::new(Some("subject".into()), Default::default()); + let now = "2026-10-28T00:00:00Z".parse::>().unwrap(); + assert_eq!( + config + .eval_flag("root", &context, ExpectedFlagType::Boolean, now) + .unwrap() + .variation_key + .as_str(), + "on", + "limit={limit}" + ); + let result = config.eval_flag_with_trace( + "dependent", + &context, + ExpectedFlagType::Boolean, + now, + EvaluationOptions::default(), + ); + assert_eq!( + result.result.unwrap_err(), + EvaluationError::ConfigurationParseError, + "limit={limit}" + ); + assert!(result.evaluations.is_empty()); + } +} + +#[test] +fn invalid_depth_limits_do_not_affect_skipped_dependency_conditions() { + let mut future = flag(vec![dependency("missing")]); + future["allocations"][0]["startAt"] = json!("2026-10-29T00:00:00Z"); + let mut expired = flag(vec![dependency("missing")]); + expired["allocations"][0]["endAt"] = json!("2026-10-27T00:00:00Z"); + let mut earlier_allocation = flag(vec![dependency("missing")]); + earlier_allocation["allocations"] + .as_array_mut() + .unwrap() + .insert(0, allocation(vec![], "on")); + let mut earlier_rule = flag(vec![dependency("missing")]); + earlier_rule["allocations"][0]["rules"] + .as_array_mut() + .unwrap() + .insert(0, json!({"conditions": []})); + for (name, root, expected) in [ + ( + "short-circuit", + flag(vec![fails(), dependency("missing")]), + "off", + ), + ("future allocation", future, "off"), + ("expired allocation", expired, "off"), + ("earlier allocation", earlier_allocation, "on"), + ("earlier rule", earlier_rule, "on"), + ] { + for limit in [json!(-1), json!(null)] { + let mut w = wire(json!({"root": root})); + w["evaluatorParams"] = json!({"maxDependencyDepth": limit}); + let result = eval(w); + assert_eq!( + result.result.unwrap().variation_key.as_str(), + expected, + "{name} limit={limit}" + ); + assert!(result.evaluations.is_empty()); + } + } +} + +#[test] +fn experimentation_opt_out_can_skip_an_invalid_dependency_limit() { + let mut root = flag(vec![dependency("missing")]); + root["allocations"][1]["doLog"] = json!(false); + let mut w = wire(json!({"root": root})); + w["evaluatorParams"] = json!({"maxDependencyDepth": null}); + let result = evaluate( + w, + EvaluationOptions { + experimentation_opt_out_mode: ExperimentationOptOutMode::Immediate, + }, + EvaluationContext::new( + Some("subject".into()), + Arc::new(HashMap::from([( + "dd_experimentation_opt_out_at".into(), + "2026-10-26T00:00:00Z".into(), + )])), + ), + ); + assert_eq!(result.result.unwrap().variation_key.as_str(), "off"); + assert!(result.evaluations.is_empty()); +} + +#[test] +fn omitted_depth_limits_default_to_ten() { + for params in [ + json!(null), + json!({}), + json!({"other": true}), + json!(false), + json!([]), + json!("ignored"), + ] { + for edges in [10, 11] { + let mut w = chain(edges); + w["evaluatorParams"] = params.clone(); + let result = eval(w); + assert_eq!( + result.result.is_ok(), + edges == 10, + "params={params} edges={edges}" + ); + if edges == 11 { + assert_eq!( + cause(result.result.as_ref().unwrap_err()), + &EvaluationError::DependencyDepthExceeded + ); + } + } + } +} + +#[test] +fn integral_json_numbers_are_valid_depth_limits() { + for (limit, edges) in [ + (json!(0), 0), + (json!(0.0), 0), + (json!(-0.0), 0), + (json!(1.0), 1), + (json!(255), 255), + (json!(255.0), 255), + ] { + let mut w = chain(edges); + w["evaluatorParams"] = json!({"maxDependencyDepth": limit}); + let bytes = serde_json::to_vec(&w).unwrap(); + assert_eq!( + UniversalFlagConfig::from_json(bytes.clone()) + .unwrap() + .to_json(), + bytes + ); + assert!(eval(w).result.is_ok(), "limit={limit}"); + } +} + +#[test] +fn depth_rejected_prerequisites_are_recorded_before_their_ancestors() { + for (edges, limit, expected) in [(1, 0, vec!["f1"]), (3, 2, vec!["f3", "f2", "f1"])] { + let mut w = chain(edges); + w["evaluatorParams"] = json!({"maxDependencyDepth": limit}); + let result = eval(w); + assert_eq!( + cause(result.result.as_ref().unwrap_err()), + &EvaluationError::DependencyDepthExceeded + ); + assert_eq!( + result + .evaluations + .iter() + .map(|record| record.flag_key.as_str()) + .collect::>(), + expected + ); + for record in &result.evaluations { + assert_eq!( + cause(record.result.as_ref().unwrap_err()), + &EvaluationError::DependencyDepthExceeded + ); + assert!(!record.exposure_eligible); + } + } +} + +#[test] +fn depth_rejection_does_not_duplicate_active_or_memoized_nodes_or_include_the_root() { + for (flags, limit, expected) in [ + (json!({"root": flag(vec![dependency("root")])}), 0, vec![]), + ( + json!({"root": flag(vec![dependency("a")]), "a": flag(vec![dependency("a")])}), + 1, + vec!["a"], + ), + ( + json!({"root": flag(vec![dependency("a"), dependency("b")]), + "a": flag(vec![]), "b": flag(vec![dependency("a")])}), + 1, + vec!["a", "b"], + ), + ] { + let mut w = wire(flags); + w["evaluatorParams"] = json!({"maxDependencyDepth": limit}); + let result = eval(w); + assert_eq!( + cause(result.result.as_ref().unwrap_err()), + &EvaluationError::DependencyDepthExceeded + ); + assert_eq!( + result + .evaluations + .iter() + .map(|record| record.flag_key.as_str()) + .collect::>(), + expected + ); + assert!( + result + .evaluations + .iter() + .all(|record| !record.exposure_eligible) + ); + } +} + +#[test] +fn memoization_cannot_bypass_depth_on_a_deeper_path_even_for_rejected_rules() { + for rejected in [false, true] { + let mut first = vec![dependency("leaf")]; + if rejected { + first.push(fails()); + } + let mut conditions = vec![dependency("a")]; + if rejected { + conditions[0]["value"] = json!(["off"]); + } + conditions.push(dependency("b")); + let mut w = wire(json!({"root": flag(conditions), "a": flag(first), + "b": flag(vec![dependency("a")]), "leaf":flag(vec![])})); + w["evaluatorParams"] = json!({"maxDependencyDepth":2}); + let result = eval(w); + assert_eq!( + cause(result.result.as_ref().unwrap_err()), + &EvaluationError::DependencyDepthExceeded + ); + } +} + +#[test] +fn memoization_is_scoped_to_each_top_level_evaluation() { + let config = Configuration::from_server_response( + UniversalFlagConfig::from_json( + serde_json::to_vec(&wire(json!({ + "root": flag(vec![dependency("parent")]), "parent":flag(vec![fails()])}))) + .unwrap(), + ) + .unwrap(), + ); + for country in ["FR", "US"] { + let context = EvaluationContext::new( + Some("same-subject".into()), + Arc::new(HashMap::from([("country".into(), country.into())])), + ); + let result = config.eval_flag_with_trace( + "root", + &context, + ExpectedFlagType::Boolean, + Utc::now(), + EvaluationOptions::default(), + ); + assert_eq!( + result.result.unwrap().variation_key.as_str(), + if country == "FR" { "on" } else { "off" } + ); + assert_eq!(result.evaluations.len(), 1); + } +} + +#[test] +fn dependencies_reuse_context_timestamp_and_experimentation_policy() { + let mut parent = flag(vec![ + json!({"attribute":"country","operator":"ONE_OF","value":["FR"]}), + ]); + parent["allocations"][0]["startAt"] = json!("2026-10-27T00:00:00Z"); + parent["allocations"][0]["endAt"] = json!("2026-10-29T00:00:00Z"); + parent["allocations"][1]["doLog"] = json!(false); + let mut root = flag(vec![dependency("parent")]); + root["allocations"][0]["doLog"] = json!(false); + root["allocations"][1]["doLog"] = json!(false); + let w = wire(json!({"root":root,"parent":parent})); + for (mode, expected) in [ + (ExperimentationOptOutMode::Disabled, "on"), + (ExperimentationOptOutMode::Immediate, "off"), + (ExperimentationOptOutMode::FutureExperimentsOnly, "off"), + ] { + let context = EvaluationContext::new( + Some("subject".into()), + Arc::new(HashMap::from([ + ("country".into(), "FR".into()), + ( + "dd_experimentation_opt_out_at".into(), + "2026-10-26T00:00:00Z".into(), + ), + ])), + ); + let result = evaluate( + w.clone(), + EvaluationOptions { + experimentation_opt_out_mode: mode, + }, + context, + ); + assert_eq!(result.result.unwrap().variation_key.as_str(), expected); + } +} + +#[test] +fn later_allocations_still_evaluate_dependencies() { + let mut root = flag(vec![dependency("parent")]); + root["allocations"] + .as_array_mut() + .unwrap() + .insert(0, allocation(vec![fails()], "off")); + let result = eval(wire(json!({"root": root, "parent": flag(vec![])}))); + assert_eq!(result.result.unwrap().variation_key.as_str(), "on"); + assert_eq!(result.evaluations.len(), 1); + assert!(result.evaluations[0].exposure_eligible); +} + +#[test] +fn independent_flags_ignore_unrelated_dependency_graphs() { + let mut w = wire(json!({"root":flag(vec![]), "cycle":flag(vec![dependency("cycle")])})); + w["evaluatorParams"] = json!({"maxDependencyDepth":0}); + let result = eval(w); + assert_eq!(result.result.unwrap().variation_key.as_str(), "on"); + assert!(result.evaluations.is_empty()); +} diff --git a/libdd-ffe/src/rules_based/eval/eval_assignment.rs b/libdd-ffe/src/rules_based/eval/eval_assignment.rs index 7778da9ad4..5352de6c88 100644 --- a/libdd-ffe/src/rules_based/eval/eval_assignment.rs +++ b/libdd-ffe/src/rules_based/eval/eval_assignment.rs @@ -2,12 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 use chrono::{DateTime, Utc}; +use std::collections::{HashMap, HashSet}; use crate::rules_based::{ - Configuration, EvaluationContext, ExpectedFlagType, Timestamp, + Configuration, EvaluationContext, ExpectedFlagType, Str, Timestamp, error::EvaluationError, timestamp::OptOutTimestamp, - ufc::{Allocation, Assignment, AssignmentReason, CompiledFlagsConfig, Flag, Shard, Split}, + ufc::{ + Allocation, Assignment, AssignmentReason, CompiledFlagsConfig, ConditionCheck, Flag, + RuleWire, Shard, Split, + }, }; /// Selects how a subject's opt-out timestamp affects exposure-logging allocations. @@ -132,11 +136,9 @@ impl Configuration { now: DateTime, options: EvaluationOptions, ) -> Result { - let exclusion = options.experimentation_exclusion(context)?; - let result = - self.flags - .compiled - .eval_flag(flag_key, context, expected_type, now, exclusion); + let result = self + .eval_flag_with_trace(flag_key, context, expected_type, now, options) + .result; match &result { Ok(assignment) => { @@ -157,22 +159,112 @@ impl Configuration { result } -} -impl CompiledFlagsConfig { - /// Evaluate the flag for the given subject, expecting `expected_type` type. - fn eval_flag( + /// Evaluate with the prerequisite records needed for SDK evaluation and exposure logging. + /// Records exclude the requested root flag and occur once per evaluated prerequisite. + /// An unsuccessful root has no eligible prerequisite exposures. SDKs must additionally + /// honor each assignment's `do_log`, consent, and exposure deduplication policy. + pub fn eval_flag_with_trace( &self, flag_key: &str, - subject: &EvaluationContext, + context: &EvaluationContext, expected_type: ExpectedFlagType, now: DateTime, - exclusion: ExperimentationExclusion, - ) -> Result { - self.get_flag(flag_key)? - .eval(subject, expected_type, now, exclusion) + options: EvaluationOptions, + ) -> EvaluationResult { + let exclusion = match options.experimentation_exclusion(context) { + Ok(exclusion) => exclusion, + Err(error) => { + return EvaluationResult { + result: Err(error), + evaluations: Vec::new(), + }; + } + }; + // Most evaluations have no dependencies. Preserve their allocation-only path rather + // than initializing memoization and exposure bookkeeping for every SDK call. + match self.flags.compiled.get_flag(flag_key) { + Ok(flag) if !flag.has_dependencies => { + return EvaluationResult { + result: flag.evaluate_independent(context, expected_type, now, exclusion), + evaluations: Vec::new(), + }; + } + Err(error) => { + return EvaluationResult { + result: Err(error), + evaluations: Vec::new(), + }; + } + Ok(_) => {} + } + let mut session = EvaluationSession { + config: &self.flags.compiled, + root_key: flag_key, + context, + now, + exclusion, + max_dependency_depth: None, + active: Vec::new(), + memo: HashMap::new(), + evaluations: Vec::new(), + }; + let evaluated = session.evaluate_flag(flag_key, expected_type, 0); + if let Ok(root) = &evaluated { + for record in &mut session.evaluations { + record.exposure_eligible = root.exposures.contains(&record.flag_key); + } + } + EvaluationResult { + result: evaluated.map(|result| result.assignment), + evaluations: session.evaluations, + } } +} + +/// One requested evaluation and the prerequisites it actually evaluated. +#[derive(Debug)] +pub struct EvaluationResult { + /// The requested flag's result; SDKs return their caller default on failure. + pub result: Result, + /// Unique prerequisite evaluations, in completion order; excludes the root. + pub evaluations: Vec, +} + +/// Telemetry for a prerequisite reached within one root evaluation. +#[derive(Debug)] +pub struct EvaluationRecord { + /// Key of the evaluated prerequisite. + pub flag_key: Str, + /// The prerequisite result, including failures. + pub result: Result, + /// Whether this result belongs to the root's successful assignment path. + /// This does not override `do_log` or the SDK's ordinary exposure policy. + pub exposure_eligible: bool, +} + +#[derive(Clone)] +struct EvaluatedFlag { + assignment: Assignment, + // Includes all reached branches, even rejected rules, to keep memoization depth-safe. + height: u16, + // Only dependencies on the selected rule/split path, not every successful evaluation. + exposures: HashSet, +} +struct EvaluationSession<'a> { + config: &'a CompiledFlagsConfig, + root_key: &'a str, + context: &'a EvaluationContext, + now: Timestamp, + exclusion: ExperimentationExclusion, + max_dependency_depth: Option, + active: Vec, + memo: HashMap, + evaluations: Vec, +} + +impl CompiledFlagsConfig { fn get_flag(&self, flag_key: &str) -> Result<&Flag, EvaluationError> { self.flags .get(flag_key) @@ -183,14 +275,11 @@ impl CompiledFlagsConfig { } impl Flag { - fn eval( + fn validate( &self, - context: &EvaluationContext, expected_type: ExpectedFlagType, - now: DateTime, exclusion: ExperimentationExclusion, - ) -> Result { - // D004: flag-wide activation validity is consumed only by effective future policy. + ) -> Result<(), EvaluationError> { if matches!(exclusion, ExperimentationExclusion::ActivatedAtOrAfter(_)) && self.has_invalid_activation { @@ -202,64 +291,224 @@ impl Flag { found: self.variation_type.into(), }); } - - let (allocation, split, reason) = self.find_allocation(context, now, exclusion)?; - - Ok(Assignment { - value: split.value.clone(), - variation_key: split.variation_key.clone(), - allocation_key: allocation.key.clone(), - reason, - serial_id: split.serial_id, - do_log: allocation.do_log, - }) + Ok(()) } - fn find_allocation( + fn evaluate_independent( &self, context: &EvaluationContext, - now: DateTime, + expected_type: ExpectedFlagType, + now: Timestamp, exclusion: ExperimentationExclusion, - ) -> Result<(&Allocation, &Split, AssignmentReason), EvaluationError> { + ) -> Result { + self.validate(expected_type, exclusion)?; for allocation in &self.allocations { - if exclusion != ExperimentationExclusion::None && exclusion.excludes(allocation) { + if !allocation.is_active(now, exclusion) { continue; } - if let Some((split, reason)) = allocation.get_matching_split(context, now)? { - return Ok((allocation, split, reason)); + if !allocation.rules.is_empty() + && !allocation.rules.iter().any(|rule| rule.eval(context)) + { + continue; + } + if let Some(split) = allocation.find_split(context)? { + return Ok(allocation.assignment(split)); } } - Err(EvaluationError::DefaultAllocationNull) } } -impl Allocation { - fn get_matching_split( - &self, - context: &EvaluationContext, - now: Timestamp, - ) -> Result, EvaluationError> { - if self.start_at.is_some_and(|t| now < t) { - return Ok(None); +impl EvaluationSession<'_> { + fn evaluate_flag( + &mut self, + flag_key: &str, + expected_type: ExpectedFlagType, + depth: u16, + ) -> Result { + let flag = self.config.get_flag(flag_key)?; + flag.validate(expected_type, self.exclusion)?; + if depth > 0 { + self.active.push(flag_key.into()); } - if self.end_at.is_some_and(|t| now > t) { - return Ok(None); + let result = self.evaluate_allocations(&flag.allocations, depth); + if depth > 0 { + self.active.pop(); } + result + } - let is_allowed_by_rules = - self.rules.is_empty() || self.rules.iter().any(|rule| rule.eval(context)); - if !is_allowed_by_rules { - return Ok(None); + fn evaluate_allocations( + &mut self, + allocations: &[Allocation], + depth: u16, + ) -> Result { + let mut height = 0; + for allocation in allocations { + if !allocation.is_active(self.now, self.exclusion) { + continue; + } + let mut selected_exposures = None; + if allocation.rules.is_empty() { + selected_exposures = Some(HashSet::new()); + } else { + for rule in &allocation.rules { + if let Some(exposures) = self.evaluate_rule(rule, depth, &mut height)? { + selected_exposures = Some(exposures); + break; + } + } + } + let Some(exposures) = selected_exposures else { + continue; + }; + let Some(split) = allocation.find_split(self.context)? else { + continue; + }; + return Ok(EvaluatedFlag { + assignment: allocation.assignment(split), + height, + exposures, + }); } + Err(EvaluationError::DefaultAllocationNull) + } - let Some(split) = self.find_split(context)? else { - return Ok(None); + fn evaluate_rule( + &mut self, + rule: &RuleWire, + depth: u16, + height: &mut u16, + ) -> Result>, EvaluationError> { + let mut exposures = HashSet::new(); + for condition in &rule.conditions { + let matched = if let ConditionCheck::FlagEvaluation { + key, + expected_membership, + values, + } = &condition.check + { + // Validate only after authored-order evaluation reaches a dependency. An invalid + // evaluator setting must not affect independent flags or skipped conditions. + let limit = self.dependency_depth_limit()?; + let dependency = + self.evaluate_dependency(key, depth + 1, limit) + .map_err(|source| EvaluationError::DependencyEvaluationFailed { + flag_key: key.clone(), + source: Box::new(source), + })?; + *height = (*height).max(1 + dependency.height); + let matched = values + .iter() + .any(|value| value.as_ref() == dependency.assignment.variation_key.as_str()) + == *expected_membership; + if matched { + exposures.insert(key.clone()); + exposures.extend(dependency.exposures); + } + matched + } else { + condition.eval(self.context) + }; + if !matched { + return Ok(None); + } + } + Ok(Some(exposures)) + } + + fn dependency_depth_limit(&mut self) -> Result { + if let Some(limit) = self.max_dependency_depth { + return Ok(limit); + } + let limit = match &self.config.max_dependency_depth { + None => 10, + Some(value) => { + let depth = value + .as_f64() + .filter(|depth| depth.fract() == 0.0 && (0.0..=255.0).contains(depth)) + .ok_or(EvaluationError::ConfigurationParseError)?; + // The checked value is an integer in 0..=255, so this conversion is lossless. + depth as u16 + } }; + self.max_dependency_depth = Some(limit); + Ok(limit) + } - // Determine the reason for assignment + fn evaluate_dependency( + &mut self, + key: &Str, + depth: u16, + limit: u16, + ) -> Result { + if depth > limit { + if key.as_str() != self.root_key + && !self.active.contains(key) + && !self.memo.contains_key(key) + { + self.evaluations.push(EvaluationRecord { + flag_key: key.clone(), + result: Err(EvaluationError::DependencyDepthExceeded), + exposure_eligible: false, + }); + } + return Err(EvaluationError::DependencyDepthExceeded); + } + if key.as_str() == self.root_key || self.active.contains(key) { + return Err(EvaluationError::DependencyCycle); + } + if let Some(result) = self.memo.get(key) { + if depth + result.height > limit { + return Err(EvaluationError::DependencyDepthExceeded); + } + return Ok(result.clone()); + } + let result = self + .evaluate_flag(key, ExpectedFlagType::Any, depth) + .and_then(|result| { + if result.assignment.variation_key.is_empty() { + Err(EvaluationError::DefaultAllocationNull) + } else { + Ok(result) + } + }); + self.evaluations.push(EvaluationRecord { + flag_key: key.clone(), + result: result + .as_ref() + .map(|result| result.assignment.clone()) + .map_err(Clone::clone), + exposure_eligible: false, + }); + if let Ok(result) = &result { + self.memo.insert(key.clone(), result.clone()); + } + result + } +} + +impl Allocation { + fn is_active(&self, now: Timestamp, exclusion: ExperimentationExclusion) -> bool { + !(exclusion != ExperimentationExclusion::None && exclusion.excludes(self) + || self.start_at.is_some_and(|t| now < t) + || self.end_at.is_some_and(|t| now > t)) + } + + fn assignment(&self, split: &Split) -> Assignment { + Assignment { + value: split.value.clone(), + variation_key: split.variation_key.clone(), + allocation_key: self.key.clone(), + reason: self.assignment_reason(split), + serial_id: split.serial_id, + do_log: self.do_log, + } + } + + fn assignment_reason(&self, split: &Split) -> AssignmentReason { let has_time_bounds = self.start_at.is_some() || self.end_at.is_some(); - let reason = if !self.rules.is_empty() { + if !self.rules.is_empty() { AssignmentReason::TargetingMatch } else if has_time_bounds && self.splits.len() == 1 && split.shards.is_empty() { AssignmentReason::Default @@ -267,9 +516,7 @@ impl Allocation { AssignmentReason::Static } else { AssignmentReason::Split - }; - - Ok(Some((split, reason))) + } } fn find_split(&self, subject: &EvaluationContext) -> Result, EvaluationError> { diff --git a/libdd-ffe/src/rules_based/eval/eval_rules.rs b/libdd-ffe/src/rules_based/eval/eval_rules.rs index 248d347347..be77ab4cf2 100644 --- a/libdd-ffe/src/rules_based/eval/eval_rules.rs +++ b/libdd-ffe/src/rules_based/eval/eval_rules.rs @@ -18,7 +18,7 @@ impl RuleWire { } impl Condition { - fn eval(&self, subject: &EvaluationContext) -> bool { + pub(super) fn eval(&self, subject: &EvaluationContext) -> bool { let attribute = subject.get_attribute(self.attribute.as_ref()); self.check.eval(attribute) } @@ -33,6 +33,8 @@ impl ConditionCheck { /// Try applying `Operator` to the values, returning `None` if the operator cannot be applied. fn try_eval(&self, attribute: Option<&Attribute>) -> Option { let result = match self { + // Dependency conditions require the root evaluation session and never read attributes. + ConditionCheck::FlagEvaluation { .. } => return None, ConditionCheck::Comparison { operator, comparand, diff --git a/libdd-ffe/src/rules_based/eval/mod.rs b/libdd-ffe/src/rules_based/eval/mod.rs index 2d465d36fc..17d00943e0 100644 --- a/libdd-ffe/src/rules_based/eval/mod.rs +++ b/libdd-ffe/src/rules_based/eval/mod.rs @@ -5,5 +5,11 @@ mod eval_assignment; mod eval_rules; mod evaluation_context; -pub use eval_assignment::{EvaluationOptions, ExperimentationOptOutMode, get_assignment}; +pub use eval_assignment::{ + EvaluationOptions, EvaluationRecord, EvaluationResult, ExperimentationOptOutMode, + get_assignment, +}; pub use evaluation_context::EvaluationContext; + +#[cfg(test)] +mod dependent_flags_tests; diff --git a/libdd-ffe/src/rules_based/mod.rs b/libdd-ffe/src/rules_based/mod.rs index 8b85eadbdc..56eaf72ba1 100644 --- a/libdd-ffe/src/rules_based/mod.rs +++ b/libdd-ffe/src/rules_based/mod.rs @@ -13,7 +13,10 @@ mod ufc; pub use attributes::Attribute; pub use configuration::Configuration; pub use error::EvaluationError; -pub use eval::{EvaluationContext, EvaluationOptions, ExperimentationOptOutMode, get_assignment}; +pub use eval::{ + EvaluationContext, EvaluationOptions, EvaluationRecord, EvaluationResult, + ExperimentationOptOutMode, get_assignment, +}; pub use str::Str; pub use timestamp::{Timestamp, now}; pub use ufc::{Assignment, AssignmentReason, AssignmentValue, UniversalFlagConfig}; diff --git a/libdd-ffe/src/rules_based/ufc/compiled_flag_config.rs b/libdd-ffe/src/rules_based/ufc/compiled_flag_config.rs index 7cd52181a8..53bfe1a1a1 100644 --- a/libdd-ffe/src/rules_based/ufc/compiled_flag_config.rs +++ b/libdd-ffe/src/rules_based/ufc/compiled_flag_config.rs @@ -22,6 +22,8 @@ pub struct UniversalFlagConfig { #[derive(Debug, Deserialize)] #[serde(from = "UniversalFlagConfigWire")] pub(crate) struct CompiledFlagsConfig { + // Preserve absence versus explicit null and defer validation until a dependency is reached. + pub max_dependency_depth: Option, /// When configuration was last updated. pub created_at: Timestamp, /// Environment this configuration belongs to. @@ -37,6 +39,7 @@ pub(crate) struct CompiledFlagsConfig { #[derive(Debug)] pub(crate) struct Flag { + pub has_dependencies: bool, pub variation_type: VariationType, pub allocations: Box<[Allocation]>, // Activation errors apply only when an opted-out subject uses future mode. @@ -100,6 +103,9 @@ impl From for CompiledFlagsConfig { .collect(); CompiledFlagsConfig { + max_dependency_depth: config + .evaluator_params + .and_then(|params| params.get("maxDependencyDepth").cloned()), created_at: config.created_at, environment: config.environment, observe_full_evaluation_data: config.observe_full_evaluation_data, @@ -137,6 +143,18 @@ fn compile_flag(flag: FlagWire) -> Result { Some(TryParse::ParseFailed(_)) ) }); + let has_dependencies = flag + .allocations + .iter() + .filter_map(|allocation| allocation.rules.as_ref()) + .flat_map(|rules| rules.iter()) + .flat_map(|rule| &rule.conditions) + .any(|condition| { + matches!( + condition.check, + super::ConditionCheck::FlagEvaluation { .. } + ) + }); let allocations = flag .allocations .into_iter() @@ -144,6 +162,7 @@ fn compile_flag(flag: FlagWire) -> Result { .collect::>()?; Ok(Flag { + has_dependencies, variation_type: flag.variation_type, allocations, has_invalid_activation, diff --git a/libdd-ffe/src/rules_based/ufc/models.rs b/libdd-ffe/src/rules_based/ufc/models.rs index 198612fba3..09fd532180 100644 --- a/libdd-ffe/src/rules_based/ufc/models.rs +++ b/libdd-ffe/src/rules_based/ufc/models.rs @@ -13,6 +13,8 @@ use crate::rules_based::{EvaluationError, FlagType, Str, Timestamp}; #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub(crate) struct UniversalFlagConfigWire { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evaluator_params: Option, /// When configuration was last updated. pub created_at: Timestamp, /// Environment this configuration belongs to. @@ -185,6 +187,11 @@ pub(crate) struct Condition { #[derive(Debug, Clone)] pub(crate) enum ConditionCheck { + FlagEvaluation { + key: Str, + expected_membership: bool, + values: Box<[Box]>, + }, Comparison { operator: ComparisonOperator, comparand: f64, @@ -255,14 +262,58 @@ impl From for ConditionOperator { #[serde(rename_all = "camelCase")] #[allow(missing_docs)] pub(crate) struct ConditionWire { - pub attribute: Box, + #[serde( + default, + deserialize_with = "deserialize_present", + skip_serializing_if = "Option::is_none" + )] + pub attribute: Option>, + #[serde( + default, + deserialize_with = "deserialize_present", + skip_serializing_if = "Option::is_none" + )] + pub flag_evaluation: Option, pub operator: ConditionOperator, pub value: ConditionValue, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct FlagEvaluationWire { + pub key: Str, +} + +// An absent selector is allowed, but an explicit null must not be treated as absent. +fn deserialize_present<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + T::deserialize(deserializer).map(Some) +} + impl From for ConditionWire { fn from(condition: Condition) -> Self { + if let ConditionCheck::FlagEvaluation { + key, + expected_membership, + values, + } = condition.check + { + return Self { + attribute: None, + flag_evaluation: Some(FlagEvaluationWire { key }), + operator: if expected_membership { + ConditionOperator::OneOf + } else { + ConditionOperator::NotOneOf + }, + value: ConditionValue::Multiple(values), + }; + } let (operator, value) = match condition.check { + ConditionCheck::FlagEvaluation { .. } => unreachable!("handled above"), ConditionCheck::Comparison { operator, comparand, @@ -303,7 +354,8 @@ impl From for ConditionWire { ), }; ConditionWire { - attribute: condition.attribute, + attribute: Some(condition.attribute), + flag_evaluation: None, operator, value, } @@ -320,7 +372,30 @@ impl TryFrom for Condition { type Error = EvaluationError; fn try_from(condition: ConditionWire) -> Result { - let attribute = condition.attribute; + if let Some(reference) = condition.flag_evaluation { + if condition.attribute.is_some() || reference.key.is_empty() { + return Err(EvaluationError::ConfigurationParseError); + } + let expected_membership = match condition.operator { + ConditionOperator::OneOf => true, + ConditionOperator::NotOneOf => false, + _ => return Err(EvaluationError::ConfigurationParseError), + }; + let ConditionValue::Multiple(values) = condition.value else { + return Err(EvaluationError::ConfigurationParseError); + }; + return Ok(Self { + attribute: "".into(), + check: ConditionCheck::FlagEvaluation { + key: reference.key, + expected_membership, + values, + }, + }); + } + let attribute = condition + .attribute + .ok_or(EvaluationError::ConfigurationParseError)?; let check = match condition.operator { ConditionOperator::Matches | ConditionOperator::NotMatches => { let expected_match = condition.operator == ConditionOperator::Matches;