diff --git a/libdd-ffe-test-suite/Cargo.toml b/libdd-ffe-test-suite/Cargo.toml index e28ab8c448..d8dd32c4bf 100644 --- a/libdd-ffe-test-suite/Cargo.toml +++ b/libdd-ffe-test-suite/Cargo.toml @@ -23,3 +23,8 @@ serde_json = { workspace = true, features = ["std", "raw_value"] } name = "ffe-eval" harness = false path = "benches/eval.rs" + +[[bench]] +name = "dependent-flags" +harness = false +path = "benches/dependent_flags.rs" 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 9ed2016102..e0d9c1fc2b 100644 --- a/libdd-ffe/README.md +++ b/libdd-ffe/README.md @@ -41,3 +41,25 @@ 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. 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). 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, 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 b4ba3f2fde..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 { @@ -27,6 +43,12 @@ pub enum EvaluationError { #[error("targeting key is missing")] TargetingKeyMissing, + /// The enabled experimentation opt-out parameter is not an RFC3339 string. + #[error( + "dd_experimentation_opt_out_at must be an RFC3339 timestamp string; supply a valid timestamp to opt out, or omit the attribute to permit experimentation" + )] + InvalidContext, + /// Failed to parse configuration. This should normally never happen and is likely a signal /// that you should update SDK. #[error("failed to parse configuration")] 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..18f60f7f6c --- /dev/null +++ b/libdd-ffe/src/rules_based/eval/dependent_flags_tests.rs @@ -0,0 +1,435 @@ +// 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"), + ] { + 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 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"}, "operator": "ONE_OF", "value": []}), + json!({"flagEvaluation": {"key": "parent"}, "operator": "NOT_ONE_OF", "value": []}), + json!({"flagEvaluation": {"key": "parent"}, "attribute": "country", "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]}), + ] { + let w = wire(json!({"root": flag(vec![invalid]), "parent": flag(vec![])})); + assert_eq!( + eval(w.clone()).result.unwrap_err(), + EvaluationError::FlagConfigurationInvalid + ); + 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 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 rejects_invalid_depth_limits_and_preserves_wire_parameters() { + for limit in [json!(-1), json!(256), json!(1.5), json!("10"), json!(true)] { + let mut w = chain(0); + w["evaluatorParams"] = json!({"maxDependencyDepth":limit}); + assert!(UniversalFlagConfig::from_json(serde_json::to_vec(&w).unwrap()).is_err()); + } + let mut w = chain(0); + w["evaluatorParams"] = json!({"maxDependencyDepth":0}); + let bytes = serde_json::to_vec(&w).unwrap(); + assert_eq!( + UniversalFlagConfig::from_json(bytes.clone()) + .unwrap() + .to_json(), + bytes + ); +} + +#[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 1969b9a996..e131923d21 100644 --- a/libdd-ffe/src/rules_based/eval/eval_assignment.rs +++ b/libdd-ffe/src/rules_based/eval/eval_assignment.rs @@ -2,13 +2,94 @@ // 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, - ufc::{Allocation, Assignment, AssignmentReason, CompiledFlagsConfig, Flag, Shard, Split}, + timestamp::OptOutTimestamp, + ufc::{ + Allocation, Assignment, AssignmentReason, CompiledFlagsConfig, ConditionCheck, Flag, + RuleWire, Shard, Split, + }, }; +/// Selects how a subject's opt-out timestamp affects exposure-logging allocations. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ExperimentationOptOutMode { + /// Ignore the opt-out attribute. + #[default] + Disabled, + /// Exclude all exposure-logging allocations immediately. + Immediate, + /// Exclude allocations activated at or after opt-out, or with unknown activation. + FutureExperimentsOnly, +} + +/// Controls optional behavior for one evaluation. The SDK resolves configuration. +#[derive(Clone, Copy, Debug, Default)] +pub struct EvaluationOptions { + /// Honor the subject's `dd_experimentation_opt_out_at` timestamp. + pub experimentation_opt_out_mode: ExperimentationOptOutMode, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum ExperimentationExclusion { + #[default] + None, + Immediate, + ActivatedAtOrAfter(Timestamp), +} + +#[cfg(test)] +thread_local! { + static EXCLUSION_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +impl ExperimentationExclusion { + fn excludes(self, allocation: &Allocation) -> bool { + #[cfg(test)] + EXCLUSION_CALLS.with(|calls| calls.set(calls.get() + 1)); + if !allocation.do_log { + return false; + } + match self { + Self::None => false, + Self::Immediate => true, + Self::ActivatedAtOrAfter(cutoff) => allocation + .randomization_activated_at + .is_none_or(|activation| activation >= cutoff), + } + } +} + +impl EvaluationOptions { + fn experimentation_exclusion( + self, + context: &EvaluationContext, + ) -> Result { + if self.experimentation_opt_out_mode == ExperimentationOptOutMode::Disabled { + return Ok(ExperimentationExclusion::None); + } + let Some(attribute) = context.get_attribute("dd_experimentation_opt_out_at") else { + return Ok(ExperimentationExclusion::None); + }; + let Some(timestamp) = attribute.as_str() else { + return Err(EvaluationError::InvalidContext); + }; + let cutoff = OptOutTimestamp::parse(timestamp) + .map_err(|_| EvaluationError::InvalidContext)? + .0; + Ok(match self.experimentation_opt_out_mode { + ExperimentationOptOutMode::Disabled => ExperimentationExclusion::None, + ExperimentationOptOutMode::Immediate => ExperimentationExclusion::Immediate, + ExperimentationOptOutMode::FutureExperimentsOnly => { + ExperimentationExclusion::ActivatedAtOrAfter(cutoff) + } + }) + } +} + /// Evaluate the specified feature flag for the given subject and return assigned variation and /// an optional assignment event for logging. pub fn get_assignment( @@ -36,11 +117,28 @@ impl Configuration { context: &EvaluationContext, expected_type: ExpectedFlagType, now: DateTime, + ) -> Result { + self.eval_flag_with_options( + flag_key, + context, + expected_type, + now, + EvaluationOptions::default(), + ) + } + + /// Evaluate a flag with SDK-selected behavior. No environment is read here. + pub fn eval_flag_with_options( + &self, + flag_key: &str, + context: &EvaluationContext, + expected_type: ExpectedFlagType, + now: DateTime, + options: EvaluationOptions, ) -> Result { let result = self - .flags - .compiled - .eval_flag(flag_key, context, expected_type, now); + .eval_flag_with_trace(flag_key, context, expected_type, now, options) + .result; match &result { Ok(assignment) => { @@ -61,20 +159,110 @@ 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, - ) -> Result { - self.get_flag(flag_key)?.eval(subject, expected_type, now) + 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, + 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, + active: Vec, + memo: HashMap, + evaluations: Vec, +} +impl CompiledFlagsConfig { fn get_flag(&self, flag_key: &str) -> Result<&Flag, EvaluationError> { self.flags .get(flag_key) @@ -85,72 +273,208 @@ impl CompiledFlagsConfig { } impl Flag { - fn eval( + fn validate( &self, - context: &EvaluationContext, expected_type: ExpectedFlagType, - now: DateTime, - ) -> Result { + exclusion: ExperimentationExclusion, + ) -> Result<(), EvaluationError> { + if matches!(exclusion, ExperimentationExclusion::ActivatedAtOrAfter(_)) + && self.has_invalid_activation + { + return Err(EvaluationError::FlagConfigurationInvalid); + } if !expected_type.is_compatible(self.variation_type.into()) { return Err(EvaluationError::TypeMismatch { expected: expected_type, found: self.variation_type.into(), }); } - - let (allocation, split, reason) = self.find_allocation(context, now)?; - - 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, - ) -> Result<(&Allocation, &Split, AssignmentReason), EvaluationError> { + expected_type: ExpectedFlagType, + now: Timestamp, + exclusion: ExperimentationExclusion, + ) -> Result { + self.validate(expected_type, exclusion)?; for allocation in &self.allocations { - if let Some((split, reason)) = allocation.get_matching_split(context, now)? { - return Ok((allocation, split, reason)); + if !allocation.is_active(now, exclusion) { + continue; + } + 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 + { + let dependency = self.evaluate_dependency(key, depth + 1).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 evaluate_dependency( + &mut self, + key: &Str, + depth: u16, + ) -> Result { + let limit = u16::from(self.config.max_dependency_depth); + if depth > limit { + 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)) + } - // Determine the reason for assignment + 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 @@ -158,9 +482,7 @@ impl Allocation { AssignmentReason::Static } else { AssignmentReason::Split - }; - - Ok(Some((split, reason))) + } } fn find_split(&self, subject: &EvaluationContext) -> Result, EvaluationError> { @@ -202,6 +524,339 @@ impl Shard { } } +#[cfg(test)] +mod opt_out_tests { + use super::*; + use crate::rules_based::{Attribute, Str, UniversalFlagConfig}; + use serde_json::json; + use std::{collections::HashMap, sync::Arc}; + + fn context(value: Option) -> EvaluationContext { + let mut attributes = HashMap::::new(); + if let Some(value) = value { + attributes.insert( + "dd_experimentation_opt_out_at".into(), + serde_json::from_value(value).unwrap(), + ); + } + EvaluationContext::new(None, Arc::new(attributes)) + } + + #[test] + fn opt_out_timestamp_validation_and_disabled_mode() { + let immediate = EvaluationOptions { + experimentation_opt_out_mode: ExperimentationOptOutMode::Immediate, + }; + for (value, expected) in [ + (None, Ok(false)), + (Some(json!("2026-10-19T12:00:00Z")), Ok(true)), + (Some(json!("2099-01-01T00:00:00.123456789+05:30")), Ok(true)), + (Some(json!(null)), Err(EvaluationError::InvalidContext)), + (Some(json!(true)), Err(EvaluationError::InvalidContext)), + (Some(json!(123)), Err(EvaluationError::InvalidContext)), + (Some(json!("")), Err(EvaluationError::InvalidContext)), + ( + Some(json!("2026-02-30T00:00:00Z")), + Err(EvaluationError::InvalidContext), + ), + ( + Some(json!("2026-10-19T12:00:00")), + Err(EvaluationError::InvalidContext), + ), + ] { + let subject = context(value.clone()); + assert_eq!( + immediate + .experimentation_exclusion(&subject) + .map(|value| value != ExperimentationExclusion::None), + expected, + "{value:?}" + ); + assert_eq!( + EvaluationOptions::default().experimentation_exclusion(&subject), + Ok(ExperimentationExclusion::None) + ); + } + } + + #[test] + fn opt_out_timestamp_syntax_is_explicit_and_errors_are_redacted() { + let immediate = EvaluationOptions { + experimentation_opt_out_mode: ExperimentationOptOutMode::Immediate, + }; + for (timestamp, accepted) in [ + ("2026-10-19T12:00:00Z", true), + ("2099-01-01T00:00:00.123456789+05:30", true), + ("2026-10-19 12:00:00Z", false), + ("2026-10-19t12:00:00z", true), + ("2016-12-31T23:59:60Z", false), + ("2026-10-19T1:00:00Z", false), + ("2026-10-19T12:00:00,123Z", false), + ("2026-10-19T12:00:00+24:00", false), + ("2026-02-30T00:00:00Z", false), + ("2026-10-19T12:00:00", false), + ("private-invalid-timestamp", false), + ("", false), + ] { + let result = immediate.experimentation_exclusion(&context(Some(json!(timestamp)))); + let expected = if accepted { + Ok(ExperimentationExclusion::Immediate) + } else { + Err(EvaluationError::InvalidContext) + }; + assert_eq!(result, expected, "{timestamp:?}"); + if let Err(error) = result { + assert!(!error.to_string().contains(timestamp) || timestamp.is_empty()); + } + } + } + + #[test] + fn opt_out_skips_before_targeting_and_preserves_ordinary_fallback() { + let config = Configuration::from_server_response( + UniversalFlagConfig::from_json(serde_json::to_vec(&json!({ + "createdAt": "2026-10-19T00:00:00Z", + "environment": {"name": "test"}, + "flags": {"feature": { + "key": "feature", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "on": {"key": "on", "value": true}, + "off": {"key": "off", "value": false} + }, + "allocations": [{ + "key": "experiment", + "splits": [{"variationKey": "on", "shards": [{ + "salt": "test", "totalShards": 10000, "ranges": [{"start": 0, "end": 5000}] + }]}] + }, { + "key": "ordinary", "doLog": false, + "splits": [{"variationKey": "off", "shards": []}] + }] + }} + })).unwrap()).unwrap() + ); + let subject = context(Some(json!("2026-10-19T00:00:00Z"))); + // Missing targeting key would fail in the first allocation if it ran. + assert_eq!( + config + .eval_flag("feature", &subject, ExpectedFlagType::Boolean, Utc::now()) + .unwrap_err(), + EvaluationError::TargetingKeyMissing, + ); + let assignment = config + .eval_flag_with_options( + "feature", + &subject, + ExpectedFlagType::Boolean, + Utc::now(), + EvaluationOptions { + experimentation_opt_out_mode: ExperimentationOptOutMode::Immediate, + }, + ) + .unwrap(); + assert_eq!(assignment.allocation_key.as_str(), "ordinary"); + assert_eq!(assignment.value.variation_value(), json!(false)); + assert!(!assignment.do_log); + } + + fn future_configuration(activation: serde_json::Value) -> Configuration { + let mut flag = json!({ + "key": "feature", "enabled": true, "variationType": "BOOLEAN", + "variations": { + "on": {"key": "on", "value": true}, + "off": {"key": "off", "value": false} + }, + "allocations": [{ + "key": "experiment", "doLog": true, + "randomizationActivatedAt": activation, + "splits": [{"variationKey": "on", "shards": [], "serialId": 7}] + }, { + "key": "ordinary", "doLog": false, + "splits": [{"variationKey": "off", "shards": [], "serialId": 8}] + }] + }); + let original = flag.clone(); + flag["key"] = json!("neighbor"); + flag["allocations"][0]["randomizationActivatedAt"] = serde_json::Value::Null; + Configuration::from_server_response( + UniversalFlagConfig::from_json( + serde_json::to_vec(&json!({ + "createdAt": "2026-10-19T00:00:00Z", + "environment": {"name": "test"}, + "flags": {"feature": original, "neighbor": flag} + })) + .unwrap(), + ) + .unwrap(), + ) + } + + #[test] + fn future_cutoff_preserves_precision_and_exposure_selection() { + let subject = context(Some(json!("2026-10-19T12:00:00Z"))); + for (activation, eligible) in [ + (json!("2026-10-19T11:59:59.999999999Z"), true), + (json!("2026-10-19T12:00:00Z"), false), + (json!("2026-10-19T12:00:00.000000001Z"), false), + (json!("2026-10-19T17:30:00+05:30"), false), + (serde_json::Value::Null, false), + ] { + let config = future_configuration(activation.clone()); + for mode in [ + ExperimentationOptOutMode::Disabled, + ExperimentationOptOutMode::Immediate, + ExperimentationOptOutMode::FutureExperimentsOnly, + ] { + let options = EvaluationOptions { + experimentation_opt_out_mode: mode, + }; + let expected = mode == ExperimentationOptOutMode::Disabled + || (mode == ExperimentationOptOutMode::FutureExperimentsOnly && eligible); + let result = config + .eval_flag_with_options( + "feature", + &subject, + ExpectedFlagType::Boolean, + Utc::now(), + options, + ) + .unwrap(); + assert_eq!( + result.value.variation_value(), + json!(expected), + "{mode:?}: {activation}" + ); + assert_eq!(result.do_log, expected); + assert_eq!( + result.allocation_key.as_str(), + if expected { "experiment" } else { "ordinary" } + ); + assert_eq!(result.serial_id, Some(if expected { 7 } else { 8 })); + let eligible_subject = config + .eval_flag_with_options( + "feature", + &context(None), + ExpectedFlagType::Boolean, + Utc::now(), + options, + ) + .unwrap(); + assert!(eligible_subject.do_log); + } + } + } + + #[test] + fn inactive_policy_does_not_call_allocation_exclusion() { + // D017: observe the production boundary, not a count inferred from the policy. + let config = future_configuration(json!("2026-10-19T12:00:00Z")); + for mode in [ + ExperimentationOptOutMode::Disabled, + ExperimentationOptOutMode::Immediate, + ExperimentationOptOutMode::FutureExperimentsOnly, + ] { + for attribute in [None, Some(json!(true)), Some(json!("2026-10-19T12:00:00Z"))] { + EXCLUSION_CALLS.with(|calls| calls.set(0)); + let result = config.eval_flag_with_options( + "feature", + &context(attribute.clone()), + ExpectedFlagType::Boolean, + Utc::now(), + EvaluationOptions { + experimentation_opt_out_mode: mode, + }, + ); + let calls = EXCLUSION_CALLS.with(|calls| calls.get()); + if mode == ExperimentationOptOutMode::Disabled || attribute.is_none() { + assert_eq!(calls, 0); + assert!(result.unwrap().do_log); + } else if attribute == Some(json!(true)) { + assert_eq!(calls, 0); + assert_eq!(result.unwrap_err(), EvaluationError::InvalidContext); + } else { + assert_eq!(calls, 2); + assert!(!result.unwrap().do_log); + } + } + } + } + + #[test] + fn malformed_activation_is_scoped_to_effective_future_mode() { + for activation in [json!("bad"), json!(42), json!(true), json!([]), json!({})] { + let config = future_configuration(activation); + for mode in [ + ExperimentationOptOutMode::Disabled, + ExperimentationOptOutMode::Immediate, + ExperimentationOptOutMode::FutureExperimentsOnly, + ] { + let options = EvaluationOptions { + experimentation_opt_out_mode: mode, + }; + for cutoff in [None, Some(json!("2026-10-19T12:00:00Z"))] { + let subject = context(cutoff.clone()); + let result = config.eval_flag_with_options( + "feature", + &subject, + ExpectedFlagType::Boolean, + Utc::now(), + options, + ); + if mode == ExperimentationOptOutMode::FutureExperimentsOnly && cutoff.is_some() + { + assert_eq!( + result.unwrap_err(), + EvaluationError::FlagConfigurationInvalid + ); + } else { + let assignment = result.unwrap(); + assert_eq!( + assignment.do_log, + mode != ExperimentationOptOutMode::Immediate || cutoff.is_none() + ); + } + assert!( + config + .eval_flag_with_options( + "neighbor", + &subject, + ExpectedFlagType::Boolean, + Utc::now(), + options, + ) + .is_ok() + ); + } + } + } + } + + #[test] + fn future_invalid_context_precedes_flag_lookup() { + let config = future_configuration(json!("2026-10-19T00:00:00Z")); + for value in [ + json!(null), + json!(true), + json!(42), + json!(""), + json!("private-invalid"), + ] { + let result = config.eval_flag_with_options( + "missing", + &context(Some(value)), + ExpectedFlagType::Boolean, + Utc::now(), + EvaluationOptions { + experimentation_opt_out_mode: ExperimentationOptOutMode::FutureExperimentsOnly, + }, + ); + assert_eq!(result.unwrap_err(), EvaluationError::InvalidContext); + } + } +} + #[cfg(test)] mod tests { use std::{collections::HashMap, sync::Arc}; 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 5cbd8dd44a..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::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 aa96117eca..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, 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/timestamp.rs b/libdd-ffe/src/rules_based/timestamp.rs index f549418a35..d1ee61bafc 100644 --- a/libdd-ffe/src/rules_based/timestamp.rs +++ b/libdd-ffe/src/rules_based/timestamp.rs @@ -2,10 +2,96 @@ // SPDX-License-Identifier: Apache-2.0 //! A simple wrapper around `chrono`, so users don’t have to depend on it directly. -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Datelike, NaiveDate, TimeDelta, Utc}; +use serde::{Deserialize, Serialize}; pub type Timestamp = DateTime; +/// Timestamp policy shared by subject cutoffs and allocation activation. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub(crate) struct OptOutTimestamp(pub Timestamp); + +impl OptOutTimestamp { + pub fn parse(value: &str) -> Result { + parse_opt_out_timestamp(value) + .map(Self) + .ok_or("invalid opt-out timestamp") + } +} + +// D005/D006/D007: exact ASCII fields, lowercase t/z, fractional truncation, and +// UTC years 0000..9999. Chrono supplies calendar arithmetic, not the input grammar. +fn parse_opt_out_timestamp(value: &str) -> Option { + let bytes = value.as_bytes(); + if bytes.len() < 20 + || bytes[4] != b'-' + || bytes[7] != b'-' + || !matches!(bytes[10], b'T' | b't') + || bytes[13] != b':' + || bytes[16] != b':' + { + return None; + } + + // All calls use fixed-width fields of at most four digits. + let decimal = |digits: &[u8]| { + digits.iter().try_fold(0_u32, |value, digit| { + digit + .is_ascii_digit() + .then(|| value * 10 + u32::from(digit - b'0')) + }) + }; + let year = i32::try_from(decimal(&bytes[..4])?).ok()?; + let date = NaiveDate::from_ymd_opt(year, decimal(&bytes[5..7])?, decimal(&bytes[8..10])?)?; + let hour = decimal(&bytes[11..13])?; + let minute = decimal(&bytes[14..16])?; + let second = decimal(&bytes[17..19])?; + + let mut position = 19; + let mut nanos = 0; + if bytes[position] == b'.' { + position += 1; + let start = position; + let mut scale = 100_000_000; + while let Some(digit) = bytes.get(position).filter(|digit| digit.is_ascii_digit()) { + // Inspect every digit, but discard precision beyond nanoseconds without rounding. + nanos += u32::from(digit - b'0') * scale; + scale /= 10; + position += 1; + } + if position == start { + return None; + } + } + let zone = &bytes[position..]; + let offset = match zone { + [b'Z' | b'z'] => 0, + [b'+' | b'-', _, _, b':', _, _] => { + let hours = decimal(&zone[1..3])?; + let minutes = decimal(&zone[4..6])?; + if hours > 23 || minutes > 59 { + return None; + } + let seconds = i64::from(hours * 3600 + minutes * 60); + if zone[0] == b'-' { -seconds } else { seconds } + } + _ => return None, + }; + let timestamp = date + .and_hms_nano_opt(hour, minute, second, nanos)? + .and_utc() + .checked_sub_signed(TimeDelta::seconds(offset))?; + (0..=9999).contains(×tamp.year()).then_some(timestamp) +} + +impl<'de> Deserialize<'de> for OptOutTimestamp { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + pub fn now() -> Timestamp { Utc::now() } 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 c58def67f5..956338ccd5 100644 --- a/libdd-ffe/src/rules_based/ufc/compiled_flag_config.rs +++ b/libdd-ffe/src/rules_based/ufc/compiled_flag_config.rs @@ -9,7 +9,7 @@ use crate::rules_based::{Str, Timestamp, error::EvaluationError, sharder::PreSal use super::{ AllocationWire, AssignmentValue, Environment, FlagWire, RuleWire, ShardRange, ShardWire, - SplitWire, UniversalFlagConfigWire, VariationType, + SplitWire, TryParse, UniversalFlagConfigWire, VariationType, }; #[derive(Debug)] @@ -22,6 +22,7 @@ pub struct UniversalFlagConfig { #[derive(Debug, Deserialize)] #[serde(from = "UniversalFlagConfigWire")] pub(crate) struct CompiledFlagsConfig { + pub max_dependency_depth: u8, /// When configuration was last updated. pub created_at: Timestamp, /// Environment this configuration belongs to. @@ -37,8 +38,11 @@ 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. + pub has_invalid_activation: bool, } #[derive(Debug)] @@ -46,6 +50,7 @@ pub(crate) struct Allocation { pub key: Str, pub start_at: Option, pub end_at: Option, + pub randomization_activated_at: Option, pub rules: Box<[RuleWire]>, pub splits: Box<[Split]>, pub do_log: bool, @@ -97,6 +102,10 @@ impl From for CompiledFlagsConfig { .collect(); CompiledFlagsConfig { + max_dependency_depth: config + .evaluator_params + .and_then(|params| params.max_dependency_depth) + .unwrap_or(10), created_at: config.created_at, environment: config.environment, observe_full_evaluation_data: config.observe_full_evaluation_data, @@ -128,6 +137,24 @@ fn compile_flag(flag: FlagWire) -> Result { }) .collect::, EvaluationError>>()?; + let has_invalid_activation = flag.allocations.iter().any(|allocation| { + matches!( + allocation.randomization_activated_at, + 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() @@ -135,8 +162,10 @@ fn compile_flag(flag: FlagWire) -> Result { .collect::>()?; Ok(Flag { + has_dependencies, variation_type: flag.variation_type, allocations, + has_invalid_activation, }) } @@ -153,6 +182,12 @@ fn compile_allocation( key: allocation.key, start_at: allocation.start_at, end_at: allocation.end_at, + randomization_activated_at: allocation + .randomization_activated_at + .and_then(|activation| match activation { + TryParse::Parsed(timestamp) => Some(timestamp.0), + TryParse::ParseFailed(_) => None, + }), rules: allocation.rules.unwrap_or_default(), splits, do_log: allocation.do_log, diff --git a/libdd-ffe/src/rules_based/ufc/models.rs b/libdd-ffe/src/rules_based/ufc/models.rs index c4434c9da0..765cdc7527 100644 --- a/libdd-ffe/src/rules_based/ufc/models.rs +++ b/libdd-ffe/src/rules_based/ufc/models.rs @@ -6,12 +6,15 @@ use std::{cmp::Ordering, collections::HashMap, num::NonZeroU32, sync::Arc}; use libdd_common::regex_engine::Regex; use serde::{Deserialize, Serialize}; +use crate::rules_based::timestamp::OptOutTimestamp; use crate::rules_based::{EvaluationError, FlagType, Str, Timestamp}; /// Universal Flag Configuration attributes. This contains the actual flag configuration data. #[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. @@ -30,6 +33,13 @@ pub(crate) struct UniversalFlagConfigWire { pub flags: HashMap>, } +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EvaluatorParams { + // u8 rejects negative, fractional, and >255 limits; zero explicitly forbids dependencies. + pub max_dependency_depth: Option, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Environment { @@ -155,6 +165,8 @@ pub(crate) struct AllocationWire { pub start_at: Option, #[serde(default)] pub end_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub randomization_activated_at: Option>, pub splits: Vec, #[serde(default = "default_do_log")] pub do_log: bool, @@ -182,6 +194,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, @@ -252,14 +269,40 @@ impl From for ConditionOperator { #[serde(rename_all = "camelCase")] #[allow(missing_docs)] pub(crate) struct ConditionWire { - pub attribute: Box, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attribute: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flag_evaluation: Option, pub operator: ConditionOperator, pub value: ConditionValue, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct FlagEvaluationWire { + pub key: Str, +} + 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, @@ -300,7 +343,8 @@ impl From for ConditionWire { ), }; ConditionWire { - attribute: condition.attribute, + attribute: Some(condition.attribute), + flag_evaluation: None, operator, value, } @@ -317,7 +361,38 @@ 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 + .as_ref() + .is_some_and(|attribute| !attribute.is_empty()) + || 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); + }; + if values.is_empty() { + 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; diff --git a/libdd-remote-config/src/fetch/fetcher.rs b/libdd-remote-config/src/fetch/fetcher.rs index d39525517f..7e57da063b 100644 --- a/libdd-remote-config/src/fetch/fetcher.rs +++ b/libdd-remote-config/src/fetch/fetcher.rs @@ -1354,4 +1354,27 @@ pub mod tests { (2u32 | (1 << 24) | (1 << 31)).to_be_bytes() ); } + + #[test] + fn test_dependent_flag_capability_encoding() { + let legacy = ConfigProductCapabilities::new( + vec![RemoteConfigProduct::FfeFlags], + vec![RemoteConfigCapabilities::FfeFlagConfigurationRules], + ); + assert_eq!(legacy.encoded_capabilities, [0x40, 0, 0, 0, 0, 0]); + + let capable = ConfigProductCapabilities::new( + vec![RemoteConfigProduct::FfeFlags], + vec![ + RemoteConfigCapabilities::FfeFlagConfigurationRules, + RemoteConfigCapabilities::FfeFlagDependentFlagVariantKeyV1, + ], + ); + assert_eq!(capable.encoded_capabilities, [0x04, 0x40, 0, 0, 0, 0, 0]); + assert_eq!( + serde_json::to_string(&RemoteConfigCapabilities::FfeFlagDependentFlagVariantKeyV1) + .unwrap(), + r#""FFE_FLAG_DEPENDENT_FLAG_VARIANT_KEY_V1""#, + ); + } } diff --git a/libdd-remote-config/src/lib.rs b/libdd-remote-config/src/lib.rs index fb051f7d26..39bb34768d 100644 --- a/libdd-remote-config/src/lib.rs +++ b/libdd-remote-config/src/lib.rs @@ -130,4 +130,7 @@ pub enum RemoteConfigCapabilities { FfeFlagConfigurationRules = 46, DdDataStreamsTransactionExtractors = 47, LlmObsActivation = 48, + /// The consumer evaluates prerequisite conditions against a flag's selected variant key. + /// Register only when the evaluator implements the dependent-flag variant-key contract. + FfeFlagDependentFlagVariantKeyV1 = 50, }