From 3eb48e6999b3792267b5179c9e1f4132298b958d Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 21 Oct 2026 21:43:12 -0400 Subject: [PATCH 1/3] feat(ffe): support subject experimentation opt-out modes --- examples/ffi/ffe.c | 60 +++ libdd-ffe-ffi/src/assignment.rs | 220 +++++++++- libdd-ffe-test-suite/ffe-system-test-data | 2 +- .../tests/canonical_fixtures.rs | 108 ++++- libdd-ffe/README.md | 20 + libdd-ffe/src/rules_based/error.rs | 6 + .../src/rules_based/eval/eval_assignment.rs | 405 +++++++++++++++++- libdd-ffe/src/rules_based/eval/mod.rs | 2 +- libdd-ffe/src/rules_based/mod.rs | 2 +- .../rules_based/ufc/compiled_flag_config.rs | 2 + libdd-ffe/src/rules_based/ufc/models.rs | 2 + 11 files changed, 815 insertions(+), 14 deletions(-) diff --git a/examples/ffi/ffe.c b/examples/ffi/ffe.c index 0c3696ae2e..070184a587 100644 --- a/examples/ffi/ffe.c +++ b/examples/ffi/ffe.c @@ -180,7 +180,67 @@ void evaluate_and_print_flag( ddog_ffe_assignment_drop(&assignment); } +static int verify_opt_out_modes(void) { + const char* 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\",\"doLog\":true,\"randomizationActivatedAt\":\"2026-10-19T12:00:00Z\"," + "\"splits\":[{\"variationKey\":\"on\",\"shards\":[]}]},{\"key\":\"ordinary\",\"doLog\":false," + "\"splits\":[{\"variationKey\":\"off\",\"shards\":[]}]}]}}}"; + struct ddog_ffe_Result_HandleConfiguration parsed = ddog_ffe_configuration_new( + (struct ddog_ffe_BorrowedStr){.ptr = (const uint8_t*)json, .len = strlen(json)} + ); + if (parsed.tag != DDOG_FFE_RESULT_HANDLE_CONFIGURATION_OK_HANDLE_CONFIGURATION) { + ddog_Error_drop(&parsed.err); + return 1; + } + ddog_ffe_Handle_Configuration config = parsed.ok; + const char* cutoffs[] = { + "2026-10-19T11:59:59.999999999Z", "2026-10-19T12:00:00Z", + "2026-10-19T12:00:00.000000001Z" + }; + const enum ddog_ffe_ExperimentationOptOutMode modes[] = { + DDOG_FFE_EXPERIMENTATION_OPT_OUT_MODE_DISABLED, + DDOG_FFE_EXPERIMENTATION_OPT_OUT_MODE_IMMEDIATE, + DDOG_FFE_EXPERIMENTATION_OPT_OUT_MODE_FUTURE_EXPERIMENTS_ONLY + }; + int failed = 0; + for (size_t i = 0; i < 3; i++) { + struct ddog_ffe_AttributePair attribute = { + .name = "dd_experimentation_opt_out_at", + .value = {.tag = DDOG_FFE_ATTRIBUTE_VALUE_STRING, .string = cutoffs[i]} + }; + ddog_ffe_Handle_EvaluationContext context = ddog_ffe_evaluation_context_new("subject", &attribute, 1); + for (size_t j = 0; j < 3; j++) { + ddog_ffe_Handle_ResolutionDetails result = ddog_ffe_get_assignment_with_opt_out_mode( + config, "feature", DDOG_FFE_EXPECTED_FLAG_TYPE_BOOLEAN, context, modes[j] + ); + struct ddog_ffe_VariantValue value = ddog_ffe_assignment_get_value(result); + bool expected = j == 0 || (j == 2 && i == 2); + failed |= value.tag != DDOG_FFE_VARIANT_VALUE_BOOLEAN || value.boolean != expected; + failed |= ddog_ffe_assignment_get_error_code(result) != DDOG_FFE_ERROR_CODE_OK; + failed |= ddog_ffe_assignment_get_do_log(result) != expected; + ddog_ffe_assignment_drop(&result); + } + struct ddog_ffe_EvaluationOptions options = {.experimentation_opt_out_immediate = true}; + ddog_ffe_Handle_ResolutionDetails old_result = ddog_ffe_get_assignment_with_options( + config, "feature", DDOG_FFE_EXPECTED_FLAG_TYPE_BOOLEAN, context, options + ); + struct ddog_ffe_VariantValue old_value = ddog_ffe_assignment_get_value(old_result); + failed |= old_value.tag != DDOG_FFE_VARIANT_VALUE_BOOLEAN || old_value.boolean; + ddog_ffe_assignment_drop(&old_result); + ddog_ffe_evaluation_context_drop(&context); + } + ddog_ffe_configuration_drop(&config); + return failed; +} + int main(int argc, char* argv[]) { + if (verify_opt_out_modes() != 0) { + fprintf(stderr, "Experimentation opt-out mode checks failed\n"); + return 1; + } printf("Datadog FFE FFI Example\n"); printf("=======================\n\n"); diff --git a/libdd-ffe-ffi/src/assignment.rs b/libdd-ffe-ffi/src/assignment.rs index 7e1d7e0f8d..fdf0c980cd 100644 --- a/libdd-ffe-ffi/src/assignment.rs +++ b/libdd-ffe-ffi/src/assignment.rs @@ -155,6 +155,112 @@ pub unsafe extern "C" fn ddog_ffe_get_assignment( flag_key: *const c_char, expected_type: ExpectedFlagType, context: Handle, +) -> Handle { + // SAFETY: this function has the same handle and string requirements. + unsafe { + ddog_ffe_get_assignment_with_options( + config, + flag_key, + expected_type, + context, + EvaluationOptions::default(), + ) + } +} + +/// Optional behavior selected by the SDK at provider construction. +#[derive(Clone, Copy, Default)] +#[repr(C)] +pub struct EvaluationOptions { + pub experimentation_opt_out_immediate: bool, +} + +/// SDK-selected handling of a subject's experimentation opt-out timestamp. +#[derive(Clone, Copy, Default)] +#[repr(C)] +pub enum ExperimentationOptOutMode { + /// Ignore the subject's opt-out attribute. + #[default] + Disabled, + /// Exclude exposure-logging allocations immediately. + Immediate, + /// Exclude allocations activated at or after opt-out, or with unknown activation. + FutureExperimentsOnly, +} + +impl From for ffe::ExperimentationOptOutMode { + fn from(mode: ExperimentationOptOutMode) -> Self { + match mode { + ExperimentationOptOutMode::Disabled => Self::Disabled, + ExperimentationOptOutMode::Immediate => Self::Immediate, + ExperimentationOptOutMode::FutureExperimentsOnly => Self::FutureExperimentsOnly, + } + } +} + +/// Evaluate a flag with optional subject experimentation exclusion. +/// +/// # Ownership +/// The caller must free the returned handle with `ddog_ffe_assignment_drop`. +/// +/// # Safety +/// `config` and `context` must be valid handles. `flag_key` must be a valid +/// NUL-terminated string. +#[unsafe(no_mangle)] +#[must_use] +pub unsafe extern "C" fn ddog_ffe_get_assignment_with_options( + config: Handle, + flag_key: *const c_char, + expected_type: ExpectedFlagType, + context: Handle, + options: EvaluationOptions, +) -> Handle { + let mode = if options.experimentation_opt_out_immediate { + ExperimentationOptOutMode::Immediate + } else { + ExperimentationOptOutMode::Disabled + }; + // SAFETY: this function has the same handle and string requirements. + unsafe { + ddog_ffe_get_assignment_with_opt_out_mode(config, flag_key, expected_type, context, mode) + } +} + +/// Evaluate a flag with SDK-selected experimentation opt-out behavior. +/// +/// # Ownership +/// The caller must free the returned handle with `ddog_ffe_assignment_drop`. +/// +/// # Safety +/// `config` and `context` must be valid handles. `flag_key` must be a valid +/// NUL-terminated string. `mode` must be a valid enum value. +#[unsafe(no_mangle)] +#[must_use] +pub unsafe extern "C" fn ddog_ffe_get_assignment_with_opt_out_mode( + config: Handle, + flag_key: *const c_char, + expected_type: ExpectedFlagType, + context: Handle, + mode: ExperimentationOptOutMode, +) -> Handle { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + // SAFETY: the caller provides valid handles and a valid string. + unsafe { get_assignment_with_mode(config, flag_key, expected_type, context, mode) } + })) + .unwrap_or_else(|_| { + Handle::new( + EvaluationError::Internal(Str::from_static_str("feature flag evaluation failed")) + .into(), + ) + }) +} + +unsafe fn get_assignment_with_mode( + config: Handle, + flag_key: *const c_char, + expected_type: ExpectedFlagType, + context: Handle, + mode: ExperimentationOptOutMode, ) -> Handle { if flag_key.is_null() { return Handle::new( @@ -180,7 +286,15 @@ pub unsafe extern "C" fn ddog_ffe_get_assignment( ); }; - let assignment_result = config.eval_flag(flag_key, context, expected_type.into(), now()); + let assignment_result = config.eval_flag_with_options( + flag_key, + context, + expected_type.into(), + now(), + ffe::EvaluationOptions { + experimentation_opt_out_mode: mode.into(), + }, + ); Handle::new(assignment_result.into()) } @@ -407,6 +521,7 @@ impl From<&EvaluationError> for ErrorCode { match value { EvaluationError::TypeMismatch { .. } => ErrorCode::TypeMismatch, EvaluationError::TargetingKeyMissing => ErrorCode::TargetingKeyMissing, + EvaluationError::InvalidContext => ErrorCode::InvalidContext, EvaluationError::ConfigurationParseError => ErrorCode::ParseError, EvaluationError::FlagConfigurationInvalid => ErrorCode::ParseError, EvaluationError::ConfigurationMissing => ErrorCode::ProviderNotReady, @@ -488,8 +603,104 @@ pub unsafe extern "C" fn ddog_ffe_assignment_drop(assignment: *mut Handle, - attributes: Arc>, + attributes: HashMap, result: TestResult, + #[serde(default)] + capabilities: HashMap, } #[derive(Debug, Deserialize)] @@ -47,6 +50,9 @@ fn evaluates_canonical_json_fixtures() { let now = Utc::now(); let mut fixture_count = 0; + let mut baseline_count = 0; + let mut opt_out_count = 0; + let mut future_opt_out_count = 0; for entry in fs::read_dir(&cases_dir).unwrap() { let path = entry.unwrap().path(); if path.extension().and_then(|ext| ext.to_str()) != Some("json") { @@ -58,7 +64,85 @@ fn evaluates_canonical_json_fixtures() { .unwrap_or_else(|err| panic!("failed to parse fixture {}: {err}", path.display())); for test_case in test_cases { - let subject = EvaluationContext::new(test_case.targeting_key, test_case.attributes); + baseline_count += 1; + let attributes = test_case + .attributes + .into_iter() + .map(|(key, value)| { + // Attribute has no container variants. Preserve this reserved input as + // present but invalid; SDK bridge tests cover the original container. + let value = if key.as_str() == "dd_experimentation_opt_out_at" + && (value.is_array() || value.is_object()) + { + serde_json::Value::Null + } else { + value + }; + (key, serde_json::from_value::(value).unwrap()) + }) + .collect(); + let subject = EvaluationContext::new(test_case.targeting_key, Arc::new(attributes)); + for (capability, mode, count) in [ + ( + "experimentationOptOutImmediate", + ExperimentationOptOutMode::Immediate, + &mut opt_out_count, + ), + ( + "experimentationOptOutFutureExperimentsOnly", + ExperimentationOptOutMode::FutureExperimentsOnly, + &mut future_opt_out_count, + ), + ] { + let Some(expected) = test_case.capabilities.get(capability) else { + continue; + }; + *count += 1; + let result = config.eval_flag_with_options( + &test_case.flag, + &subject, + test_case.variation_type.into(), + now, + EvaluationOptions { + experimentation_opt_out_mode: mode, + }, + ); + let (value, reason, error) = match result { + Ok(assignment) => ( + assignment.value.variation_value(), + reason_from_assignment(assignment.reason), + None, + ), + Err(err) => ( + test_case.default_value.clone(), + reason_from_error(&err), + error_code_from_error(&err), + ), + }; + assert_eq!( + value, + expected.value, + "{capability} value for {} in {}", + test_case.flag, + path.display() + ); + if let Some(expected_reason) = expected.reason.as_deref() { + assert_eq!( + reason, + expected_reason, + "{capability} reason for {} in {}", + test_case.flag, + path.display() + ); + } + assert_eq!( + error, + expected.error_code.as_deref(), + "{capability} error for {} in {}", + test_case.flag, + path.display() + ); + } let result = get_assignment( Some(&config), &test_case.flag, @@ -111,6 +195,21 @@ fn evaluates_canonical_json_fixtures() { } assert!(fixture_count > 0, "no canonical FFE fixtures loaded"); + assert!( + baseline_count >= 325, + "baseline fixture coverage was reduced" + ); + assert!( + opt_out_count > 0, + "immediate opt-out capability was not exercised" + ); + assert!( + future_opt_out_count > 0, + "future opt-out capability was not exercised" + ); + eprintln!( + "canonical fixtures: {baseline_count} baseline, {opt_out_count} immediate, {future_opt_out_count} future" + ); } fn reason_from_assignment(reason: AssignmentReason) -> &'static str { @@ -135,6 +234,7 @@ fn error_code_from_error(err: &EvaluationError) -> Option<&'static str> { EvaluationError::FlagDisabled | EvaluationError::DefaultAllocationNull => None, EvaluationError::TypeMismatch { .. } => Some("TYPE_MISMATCH"), EvaluationError::TargetingKeyMissing => Some("TARGETING_KEY_MISSING"), + EvaluationError::InvalidContext => Some("INVALID_CONTEXT"), EvaluationError::ConfigurationParseError | EvaluationError::FlagConfigurationInvalid => { Some("PARSE_ERROR") } diff --git a/libdd-ffe/README.md b/libdd-ffe/README.md index a3b2511f52..5004893cf6 100644 --- a/libdd-ffe/README.md +++ b/libdd-ffe/README.md @@ -2,6 +2,26 @@ This is an internal DataDog library that supports Feature Flag evaluation. It is intended for use in tracers and DataDog services. Expect frequent breaking changes and major bumps before it's stabilized. +## Subject experimentation opt-out + +The caller selects `EvaluationOptions.experimentation_opt_out_mode`; the evaluator reads no environment variables. +Both enabled modes require a valid `dd_experimentation_opt_out_at` timestamp when the attribute is present. +Missing input preserves normal evaluation. Invalid input returns `InvalidContext` before flag lookup. + +| Mode | Exposure-logging allocations | +| --- | --- | +| `Disabled` | Evaluate normally | +| `Immediate` | Skip when the subject supplies an opt-out timestamp | +| `FutureExperimentsOnly` | Skip when activation is equal to or later than opt-out, or activation is unknown | + +Future mode uses `randomizationActivatedAt`, with nanosecond precision and timezone-aware comparisons. +Older allocations remain eligible; this does not prove previous enrollment. +Malformed activation rejects the affected flag in every mode. Missing or null activation is unknown. +Skipped allocations produce no exposure. Ordinary allocations remain available, and evaluation telemetry is unchanged. + +The C API adds `ddog_ffe_get_assignment_with_opt_out_mode`. +The existing `ddog_ffe_get_assignment_with_options` interface and boolean layout remain unchanged for immediate-mode callers. + ## CDN configuration Enable `cdn,https` to fetch managed or custom CDN configuration on an existing diff --git a/libdd-ffe/src/rules_based/error.rs b/libdd-ffe/src/rules_based/error.rs index b4ba3f2fde..38538a460f 100644 --- a/libdd-ffe/src/rules_based/error.rs +++ b/libdd-ffe/src/rules_based/error.rs @@ -27,6 +27,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/eval_assignment.rs b/libdd-ffe/src/rules_based/eval/eval_assignment.rs index 1969b9a996..ec8bb2d5bc 100644 --- a/libdd-ffe/src/rules_based/eval/eval_assignment.rs +++ b/libdd-ffe/src/rules_based/eval/eval_assignment.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use chrono::{DateTime, Utc}; +use serde::{Deserialize, de::value::StrDeserializer}; use crate::rules_based::{ Configuration, EvaluationContext, ExpectedFlagType, Timestamp, @@ -9,6 +10,76 @@ use crate::rules_based::{ ufc::{Allocation, Assignment, AssignmentReason, CompiledFlagsConfig, Flag, 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), +} + +impl ExperimentationExclusion { + fn excludes(self, allocation: &Allocation) -> bool { + 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); + }; + // Use the same timestamp deserializer as allocation startAt and endAt. + let cutoff = + Timestamp::deserialize(StrDeserializer::::new(timestamp)) + .map_err(|_| EvaluationError::InvalidContext)?; + 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( @@ -37,10 +108,29 @@ impl Configuration { expected_type: ExpectedFlagType, now: DateTime, ) -> Result { - let result = self - .flags - .compiled - .eval_flag(flag_key, context, expected_type, now); + 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 exclusion = options.experimentation_exclusion(context)?; + let result = + self.flags + .compiled + .eval_flag(flag_key, context, expected_type, now, exclusion); match &result { Ok(assignment) => { @@ -71,8 +161,10 @@ impl CompiledFlagsConfig { subject: &EvaluationContext, expected_type: ExpectedFlagType, now: DateTime, + exclusion: ExperimentationExclusion, ) -> Result { - self.get_flag(flag_key)?.eval(subject, expected_type, now) + self.get_flag(flag_key)? + .eval(subject, expected_type, now, exclusion) } fn get_flag(&self, flag_key: &str) -> Result<&Flag, EvaluationError> { @@ -90,6 +182,7 @@ impl Flag { context: &EvaluationContext, expected_type: ExpectedFlagType, now: DateTime, + exclusion: ExperimentationExclusion, ) -> Result { if !expected_type.is_compatible(self.variation_type.into()) { return Err(EvaluationError::TypeMismatch { @@ -98,7 +191,7 @@ impl Flag { }); } - let (allocation, split, reason) = self.find_allocation(context, now)?; + let (allocation, split, reason) = self.find_allocation(context, now, exclusion)?; Ok(Assignment { value: split.value.clone(), @@ -114,8 +207,12 @@ impl Flag { &self, context: &EvaluationContext, now: DateTime, + exclusion: ExperimentationExclusion, ) -> Result<(&Allocation, &Split, AssignmentReason), EvaluationError> { for allocation in &self.allocations { + if exclusion.excludes(allocation) { + continue; + } if let Some((split, reason)) = allocation.get_matching_split(context, now)? { return Ok((allocation, split, reason)); } @@ -202,6 +299,302 @@ 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_uses_allocation_timestamp_contract() { + let immediate = EvaluationOptions { + experimentation_opt_out_mode: ExperimentationOptOutMode::Immediate, + }; + for timestamp in [ + "2026-10-19T12:00:00Z", + "2099-01-01T00:00:00.123456789+05:30", + "2026-10-19 12:00:00Z", + "2026-10-19t12:00:00z", + "2016-12-31T23:59:60Z", + "2026-10-19T1:00:00Z", + "2026-10-19T12:00:00,123Z", + "2026-10-19T12:00:00+24:00", + "2026-02-30T00:00:00Z", + "2026-10-19T12:00:00", + "private-invalid-timestamp", + "", + ] { + let allocation = + serde_json::from_value::(json!({ + "key": "test", + "startAt": timestamp, + "endAt": timestamp, + "splits": [], + "doLog": true, + })); + let result = immediate.experimentation_exclusion(&context(Some(json!(timestamp)))); + let expected = if allocation.is_ok() { + 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 malformed_activation_is_isolated_in_every_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, + }; + let subject = context(Some(json!("2026-10-19T12:00:00Z"))); + assert_eq!( + config + .eval_flag_with_options( + "feature", + &subject, + ExpectedFlagType::Boolean, + Utc::now(), + options, + ) + .unwrap_err(), + EvaluationError::FlagConfigurationInvalid + ); + 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/mod.rs b/libdd-ffe/src/rules_based/eval/mod.rs index 5cbd8dd44a..2d465d36fc 100644 --- a/libdd-ffe/src/rules_based/eval/mod.rs +++ b/libdd-ffe/src/rules_based/eval/mod.rs @@ -5,5 +5,5 @@ mod eval_assignment; mod eval_rules; mod evaluation_context; -pub use eval_assignment::get_assignment; +pub use eval_assignment::{EvaluationOptions, ExperimentationOptOutMode, get_assignment}; pub use evaluation_context::EvaluationContext; diff --git a/libdd-ffe/src/rules_based/mod.rs b/libdd-ffe/src/rules_based/mod.rs index aa96117eca..8b85eadbdc 100644 --- a/libdd-ffe/src/rules_based/mod.rs +++ b/libdd-ffe/src/rules_based/mod.rs @@ -13,7 +13,7 @@ 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, 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 c58def67f5..dda6357e39 100644 --- a/libdd-ffe/src/rules_based/ufc/compiled_flag_config.rs +++ b/libdd-ffe/src/rules_based/ufc/compiled_flag_config.rs @@ -46,6 +46,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, @@ -153,6 +154,7 @@ fn compile_allocation( key: allocation.key, start_at: allocation.start_at, end_at: allocation.end_at, + randomization_activated_at: allocation.randomization_activated_at, 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..34d97f48e7 100644 --- a/libdd-ffe/src/rules_based/ufc/models.rs +++ b/libdd-ffe/src/rules_based/ufc/models.rs @@ -155,6 +155,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, From ead1d3db2aa16e494f23e47b946ff12a7a1be53a Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 23 Oct 2026 08:53:27 -0400 Subject: [PATCH 2/3] fix(ffe): align experimentation opt-out with shared contracts --- libdd-ffe-ffi/src/assignment.rs | 28 +- libdd-ffe-test-suite/ffe-system-test-data | 2 +- libdd-ffe-test-suite/tests/data/README.md | 11 + .../data/experimentation-opt-out-policy.jsonl | 90 +++++ .../tests/experimentation_opt_out.rs | 332 ++++++++++++++++++ libdd-ffe/README.md | 4 +- .../src/rules_based/eval/eval_assignment.rs | 121 +++++-- libdd-ffe/src/rules_based/timestamp.rs | 26 ++ .../rules_based/ufc/compiled_flag_config.rs | 18 +- libdd-ffe/src/rules_based/ufc/models.rs | 3 +- 10 files changed, 595 insertions(+), 40 deletions(-) create mode 100644 libdd-ffe-test-suite/tests/data/README.md create mode 100644 libdd-ffe-test-suite/tests/data/experimentation-opt-out-policy.jsonl create mode 100644 libdd-ffe-test-suite/tests/experimentation_opt_out.rs diff --git a/libdd-ffe-ffi/src/assignment.rs b/libdd-ffe-ffi/src/assignment.rs index fdf0c980cd..ba5d2059fc 100644 --- a/libdd-ffe-ffi/src/assignment.rs +++ b/libdd-ffe-ffi/src/assignment.rs @@ -243,11 +243,18 @@ pub unsafe extern "C" fn ddog_ffe_get_assignment_with_opt_out_mode( context: Handle, mode: ExperimentationOptOutMode, ) -> Handle { - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + catch_assignment_panic(|| { // SAFETY: the caller provides valid handles and a valid string. unsafe { get_assignment_with_mode(config, flag_key, expected_type, context, mode) } - })) - .unwrap_or_else(|_| { + }) +} + +// Other FFI crates have private catch_panic! macros tied to their own error types. +// Keep this boundary's handle ownership and redacted error conversion together. +fn catch_assignment_panic( + evaluate: impl FnOnce() -> Handle, +) -> Handle { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(evaluate)).unwrap_or_else(|_| { Handle::new( EvaluationError::Internal(Str::from_static_str("feature flag evaluation failed")) .into(), @@ -607,6 +614,21 @@ mod tests { use super::*; + #[test] + fn evaluation_panic_returns_owned_redacted_error() { + let mut result = catch_assignment_panic(|| panic!("private context value")); + // SAFETY: the returned handle is live until it is freed below. + let details = unsafe { result.as_ref() }; + assert_eq!(ErrorCode::from(details), ErrorCode::General); + assert_eq!( + details.error_message.as_deref(), + Some("internal error: feature flag evaluation failed") + ); + assert!(details.flag_metadata.is_empty()); + // SAFETY: all borrows ended; free the owned result once. + unsafe { ddog_ffe_assignment_drop(&mut result) }; + } + #[test] fn opt_out_modes_cross_the_ffi_boundary() { let wire = ffe::UniversalFlagConfig::from_json( diff --git a/libdd-ffe-test-suite/ffe-system-test-data b/libdd-ffe-test-suite/ffe-system-test-data index b288762001..0781ce2c55 160000 --- a/libdd-ffe-test-suite/ffe-system-test-data +++ b/libdd-ffe-test-suite/ffe-system-test-data @@ -1 +1 @@ -Subproject commit b28876200152f00c7d5eb04616f721afa5955c1d +Subproject commit 0781ce2c55f9da7f9290035c768bbc46570556c0 diff --git a/libdd-ffe-test-suite/tests/data/README.md b/libdd-ffe-test-suite/tests/data/README.md new file mode 100644 index 0000000000..d9b976369c --- /dev/null +++ b/libdd-ffe-test-suite/tests/data/README.md @@ -0,0 +1,11 @@ +# Experimentation opt-out policy cases + +`experimentation-opt-out-policy.jsonl` contains all 90 `policy` cases from the +[shared Lean contract](https://github.com/DataDog/ffe-system-test-data/blob/f8a4176cd8360b12b56a8b6ec6ef215e2cc21077/contracts/experimentation-opt-out/corpus/browser-proposal.jsonl). + +Source SHA256: `c4a1b9c9eed7197b8d66c1d3e46fcaaa4ec70143a2583b31baa04086c8f652dd`. +Extracted policy SHA256: `0d396beb2b627606f795386340d5e9d7443a40c51e84200b60527938969f7175`. + +The cases cover D002, D004 revision 2, D010 for valid boolean input, and D011. +The adapter uses the production Rust evaluator. It preserves every expected result. +Timestamp grammar, provider configuration, transport, and telemetry delivery are outside this profile. diff --git a/libdd-ffe-test-suite/tests/data/experimentation-opt-out-policy.jsonl b/libdd-ffe-test-suite/tests/data/experimentation-opt-out-policy.jsonl new file mode 100644 index 0000000000..c7a0677332 --- /dev/null +++ b/libdd-ffe-test-suite/tests/data/experimentation-opt-out-policy.jsonl @@ -0,0 +1,90 @@ +{"args": {"activation": "missing", "attribute": "absent", "doLog": false, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/absent/missing/0", "kind": "policy"} +{"args": {"activation": "missing", "attribute": "absent", "doLog": true, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/absent/missing/1", "kind": "policy"} +{"args": {"activation": "invalid", "attribute": "absent", "doLog": false, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/absent/invalid/0", "kind": "policy"} +{"args": {"activation": "invalid", "attribute": "absent", "doLog": true, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/absent/invalid/1", "kind": "policy"} +{"args": {"activation": "before", "attribute": "absent", "doLog": false, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/absent/before/0", "kind": "policy"} +{"args": {"activation": "before", "attribute": "absent", "doLog": true, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/absent/before/1", "kind": "policy"} +{"args": {"activation": "equal", "attribute": "absent", "doLog": false, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/absent/equal/0", "kind": "policy"} +{"args": {"activation": "equal", "attribute": "absent", "doLog": true, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/absent/equal/1", "kind": "policy"} +{"args": {"activation": "after", "attribute": "absent", "doLog": false, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/absent/after/0", "kind": "policy"} +{"args": {"activation": "after", "attribute": "absent", "doLog": true, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/absent/after/1", "kind": "policy"} +{"args": {"activation": "missing", "attribute": "valid", "doLog": false, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/valid/missing/0", "kind": "policy"} +{"args": {"activation": "missing", "attribute": "valid", "doLog": true, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/valid/missing/1", "kind": "policy"} +{"args": {"activation": "invalid", "attribute": "valid", "doLog": false, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/valid/invalid/0", "kind": "policy"} +{"args": {"activation": "invalid", "attribute": "valid", "doLog": true, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/valid/invalid/1", "kind": "policy"} +{"args": {"activation": "before", "attribute": "valid", "doLog": false, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/valid/before/0", "kind": "policy"} +{"args": {"activation": "before", "attribute": "valid", "doLog": true, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/valid/before/1", "kind": "policy"} +{"args": {"activation": "equal", "attribute": "valid", "doLog": false, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/valid/equal/0", "kind": "policy"} +{"args": {"activation": "equal", "attribute": "valid", "doLog": true, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/valid/equal/1", "kind": "policy"} +{"args": {"activation": "after", "attribute": "valid", "doLog": false, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/valid/after/0", "kind": "policy"} +{"args": {"activation": "after", "attribute": "valid", "doLog": true, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/valid/after/1", "kind": "policy"} +{"args": {"activation": "missing", "attribute": "invalid", "doLog": false, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/invalid/missing/0", "kind": "policy"} +{"args": {"activation": "missing", "attribute": "invalid", "doLog": true, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/invalid/missing/1", "kind": "policy"} +{"args": {"activation": "invalid", "attribute": "invalid", "doLog": false, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/invalid/invalid/0", "kind": "policy"} +{"args": {"activation": "invalid", "attribute": "invalid", "doLog": true, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/invalid/invalid/1", "kind": "policy"} +{"args": {"activation": "before", "attribute": "invalid", "doLog": false, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/invalid/before/0", "kind": "policy"} +{"args": {"activation": "before", "attribute": "invalid", "doLog": true, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/invalid/before/1", "kind": "policy"} +{"args": {"activation": "equal", "attribute": "invalid", "doLog": false, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/invalid/equal/0", "kind": "policy"} +{"args": {"activation": "equal", "attribute": "invalid", "doLog": true, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/invalid/equal/1", "kind": "policy"} +{"args": {"activation": "after", "attribute": "invalid", "doLog": false, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/invalid/after/0", "kind": "policy"} +{"args": {"activation": "after", "attribute": "invalid", "doLog": true, "mode": "disabled"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/disabled/invalid/after/1", "kind": "policy"} +{"args": {"activation": "missing", "attribute": "absent", "doLog": false, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/immediate/absent/missing/0", "kind": "policy"} +{"args": {"activation": "missing", "attribute": "absent", "doLog": true, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/immediate/absent/missing/1", "kind": "policy"} +{"args": {"activation": "invalid", "attribute": "absent", "doLog": false, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/immediate/absent/invalid/0", "kind": "policy"} +{"args": {"activation": "invalid", "attribute": "absent", "doLog": true, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/immediate/absent/invalid/1", "kind": "policy"} +{"args": {"activation": "before", "attribute": "absent", "doLog": false, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/immediate/absent/before/0", "kind": "policy"} +{"args": {"activation": "before", "attribute": "absent", "doLog": true, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/immediate/absent/before/1", "kind": "policy"} +{"args": {"activation": "equal", "attribute": "absent", "doLog": false, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/immediate/absent/equal/0", "kind": "policy"} +{"args": {"activation": "equal", "attribute": "absent", "doLog": true, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/immediate/absent/equal/1", "kind": "policy"} +{"args": {"activation": "after", "attribute": "absent", "doLog": false, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/immediate/absent/after/0", "kind": "policy"} +{"args": {"activation": "after", "attribute": "absent", "doLog": true, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/immediate/absent/after/1", "kind": "policy"} +{"args": {"activation": "missing", "attribute": "valid", "doLog": false, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/immediate/valid/missing/0", "kind": "policy"} +{"args": {"activation": "missing", "attribute": "valid", "doLog": true, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": true}, "id": "policy/immediate/valid/missing/1", "kind": "policy"} +{"args": {"activation": "invalid", "attribute": "valid", "doLog": false, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/immediate/valid/invalid/0", "kind": "policy"} +{"args": {"activation": "invalid", "attribute": "valid", "doLog": true, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": true}, "id": "policy/immediate/valid/invalid/1", "kind": "policy"} +{"args": {"activation": "before", "attribute": "valid", "doLog": false, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/immediate/valid/before/0", "kind": "policy"} +{"args": {"activation": "before", "attribute": "valid", "doLog": true, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": true}, "id": "policy/immediate/valid/before/1", "kind": "policy"} +{"args": {"activation": "equal", "attribute": "valid", "doLog": false, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/immediate/valid/equal/0", "kind": "policy"} +{"args": {"activation": "equal", "attribute": "valid", "doLog": true, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": true}, "id": "policy/immediate/valid/equal/1", "kind": "policy"} +{"args": {"activation": "after", "attribute": "valid", "doLog": false, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/immediate/valid/after/0", "kind": "policy"} +{"args": {"activation": "after", "attribute": "valid", "doLog": true, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": true}, "id": "policy/immediate/valid/after/1", "kind": "policy"} +{"args": {"activation": "missing", "attribute": "invalid", "doLog": false, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/immediate/invalid/missing/0", "kind": "policy"} +{"args": {"activation": "missing", "attribute": "invalid", "doLog": true, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/immediate/invalid/missing/1", "kind": "policy"} +{"args": {"activation": "invalid", "attribute": "invalid", "doLog": false, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/immediate/invalid/invalid/0", "kind": "policy"} +{"args": {"activation": "invalid", "attribute": "invalid", "doLog": true, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/immediate/invalid/invalid/1", "kind": "policy"} +{"args": {"activation": "before", "attribute": "invalid", "doLog": false, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/immediate/invalid/before/0", "kind": "policy"} +{"args": {"activation": "before", "attribute": "invalid", "doLog": true, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/immediate/invalid/before/1", "kind": "policy"} +{"args": {"activation": "equal", "attribute": "invalid", "doLog": false, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/immediate/invalid/equal/0", "kind": "policy"} +{"args": {"activation": "equal", "attribute": "invalid", "doLog": true, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/immediate/invalid/equal/1", "kind": "policy"} +{"args": {"activation": "after", "attribute": "invalid", "doLog": false, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/immediate/invalid/after/0", "kind": "policy"} +{"args": {"activation": "after", "attribute": "invalid", "doLog": true, "mode": "immediate"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/immediate/invalid/after/1", "kind": "policy"} +{"args": {"activation": "missing", "attribute": "absent", "doLog": false, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/future_experiments_only/absent/missing/0", "kind": "policy"} +{"args": {"activation": "missing", "attribute": "absent", "doLog": true, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/future_experiments_only/absent/missing/1", "kind": "policy"} +{"args": {"activation": "invalid", "attribute": "absent", "doLog": false, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/future_experiments_only/absent/invalid/0", "kind": "policy"} +{"args": {"activation": "invalid", "attribute": "absent", "doLog": true, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/future_experiments_only/absent/invalid/1", "kind": "policy"} +{"args": {"activation": "before", "attribute": "absent", "doLog": false, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/future_experiments_only/absent/before/0", "kind": "policy"} +{"args": {"activation": "before", "attribute": "absent", "doLog": true, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/future_experiments_only/absent/before/1", "kind": "policy"} +{"args": {"activation": "equal", "attribute": "absent", "doLog": false, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/future_experiments_only/absent/equal/0", "kind": "policy"} +{"args": {"activation": "equal", "attribute": "absent", "doLog": true, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/future_experiments_only/absent/equal/1", "kind": "policy"} +{"args": {"activation": "after", "attribute": "absent", "doLog": false, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/future_experiments_only/absent/after/0", "kind": "policy"} +{"args": {"activation": "after", "attribute": "absent", "doLog": true, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/future_experiments_only/absent/after/1", "kind": "policy"} +{"args": {"activation": "missing", "attribute": "valid", "doLog": false, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/future_experiments_only/valid/missing/0", "kind": "policy"} +{"args": {"activation": "missing", "attribute": "valid", "doLog": true, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": true}, "id": "policy/future_experiments_only/valid/missing/1", "kind": "policy"} +{"args": {"activation": "invalid", "attribute": "valid", "doLog": false, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "PARSE_ERROR", "exclude": false}, "id": "policy/future_experiments_only/valid/invalid/0", "kind": "policy"} +{"args": {"activation": "invalid", "attribute": "valid", "doLog": true, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "PARSE_ERROR", "exclude": false}, "id": "policy/future_experiments_only/valid/invalid/1", "kind": "policy"} +{"args": {"activation": "before", "attribute": "valid", "doLog": false, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/future_experiments_only/valid/before/0", "kind": "policy"} +{"args": {"activation": "before", "attribute": "valid", "doLog": true, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/future_experiments_only/valid/before/1", "kind": "policy"} +{"args": {"activation": "equal", "attribute": "valid", "doLog": false, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/future_experiments_only/valid/equal/0", "kind": "policy"} +{"args": {"activation": "equal", "attribute": "valid", "doLog": true, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": true}, "id": "policy/future_experiments_only/valid/equal/1", "kind": "policy"} +{"args": {"activation": "after", "attribute": "valid", "doLog": false, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": false}, "id": "policy/future_experiments_only/valid/after/0", "kind": "policy"} +{"args": {"activation": "after", "attribute": "valid", "doLog": true, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "NONE", "exclude": true}, "id": "policy/future_experiments_only/valid/after/1", "kind": "policy"} +{"args": {"activation": "missing", "attribute": "invalid", "doLog": false, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/future_experiments_only/invalid/missing/0", "kind": "policy"} +{"args": {"activation": "missing", "attribute": "invalid", "doLog": true, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/future_experiments_only/invalid/missing/1", "kind": "policy"} +{"args": {"activation": "invalid", "attribute": "invalid", "doLog": false, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/future_experiments_only/invalid/invalid/0", "kind": "policy"} +{"args": {"activation": "invalid", "attribute": "invalid", "doLog": true, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/future_experiments_only/invalid/invalid/1", "kind": "policy"} +{"args": {"activation": "before", "attribute": "invalid", "doLog": false, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/future_experiments_only/invalid/before/0", "kind": "policy"} +{"args": {"activation": "before", "attribute": "invalid", "doLog": true, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/future_experiments_only/invalid/before/1", "kind": "policy"} +{"args": {"activation": "equal", "attribute": "invalid", "doLog": false, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/future_experiments_only/invalid/equal/0", "kind": "policy"} +{"args": {"activation": "equal", "attribute": "invalid", "doLog": true, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/future_experiments_only/invalid/equal/1", "kind": "policy"} +{"args": {"activation": "after", "attribute": "invalid", "doLog": false, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/future_experiments_only/invalid/after/0", "kind": "policy"} +{"args": {"activation": "after", "attribute": "invalid", "doLog": true, "mode": "future_experiments_only"}, "decision_ids": ["D002", "D004", "D010", "D011"], "expected": {"error": "INVALID_CONTEXT", "exclude": false}, "id": "policy/future_experiments_only/invalid/after/1", "kind": "policy"} diff --git a/libdd-ffe-test-suite/tests/experimentation_opt_out.rs b/libdd-ffe-test-suite/tests/experimentation_opt_out.rs new file mode 100644 index 0000000000..ddf9c0e3a0 --- /dev/null +++ b/libdd-ffe-test-suite/tests/experimentation_opt_out.rs @@ -0,0 +1,332 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use std::{collections::HashMap, sync::Arc}; + +use chrono::Utc; +use libdd_ffe::rules_based::{ + Assignment, Attribute, Configuration, EvaluationContext, EvaluationError, EvaluationOptions, + ExpectedFlagType, ExperimentationOptOutMode, Str, UniversalFlagConfig, +}; +use serde::Deserialize; +use serde_json::{Value, json}; + +#[derive(Deserialize)] +struct PolicyCase { + id: String, + args: PolicyArgs, + expected: PolicyResult, +} + +#[derive(Deserialize)] +struct PolicyArgs { + mode: String, + attribute: String, + activation: String, + #[serde(rename = "doLog")] + do_log: bool, +} + +#[derive(Deserialize)] +struct PolicyResult { + error: String, + exclude: bool, +} + +fn flag(activation: Value, do_log: bool) -> Value { + json!({ + "key": "feature", "enabled": true, "variationType": "STRING", + "variations": { + "experiment": {"key": "experiment", "value": "experiment"}, + "ordinary": {"key": "ordinary", "value": "ordinary"} + }, + "allocations": [{ + "key": "candidate", "doLog": do_log, "randomizationActivatedAt": activation, + "splits": [{"variationKey": "experiment", "shards": [], "serialId": 7}] + }, { + "key": "fallback", "doLog": false, + "splits": [{"variationKey": "ordinary", "shards": [], "serialId": 8}] + }] + }) +} + +fn configuration(flag: Value) -> Configuration { + Configuration::from_server_response( + UniversalFlagConfig::from_json( + serde_json::to_vec(&json!({ + "createdAt": "2026-01-01T00:00:00Z", "environment": {"name": "test"}, + "flags": {"feature": flag, "neighbor": self::flag(Value::Null, false)} + })) + .unwrap(), + ) + .unwrap(), + ) +} + +fn context(attribute: &str) -> EvaluationContext { + let mut attributes = HashMap::::new(); + match attribute { + "absent" => {} + "valid" => { + attributes.insert( + "dd_experimentation_opt_out_at".into(), + "2026-01-01T00:00:00Z".into(), + ); + } + "invalid" => { + attributes.insert("dd_experimentation_opt_out_at".into(), true.into()); + } + _ => panic!("unknown attribute state: {attribute}"), + } + EvaluationContext::new(Some("subject".into()), Arc::new(attributes)) +} + +fn evaluate( + config: &Configuration, + key: &str, + mode: ExperimentationOptOutMode, + attribute: &str, +) -> Result { + config.eval_flag_with_options( + key, + &context(attribute), + ExpectedFlagType::String, + Utc::now(), + EvaluationOptions { + experimentation_opt_out_mode: mode, + }, + ) +} + +const MODES: [ExperimentationOptOutMode; 3] = [ + ExperimentationOptOutMode::Disabled, + ExperimentationOptOutMode::Immediate, + ExperimentationOptOutMode::FutureExperimentsOnly, +]; + +#[test] +fn lean_policy_cases_use_production_evaluator() { + // D002/D004 r2/D010/D011. Expectations are copied unchanged from the Lean oracle. + let mut count = 0; + for line in include_str!("data/experimentation-opt-out-policy.jsonl").lines() { + let case: PolicyCase = serde_json::from_str(line).unwrap(); + let mode = match case.args.mode.as_str() { + "disabled" => MODES[0], + "immediate" => MODES[1], + "future_experiments_only" => MODES[2], + _ => panic!("unknown mode: {}", case.id), + }; + let activation = match case.args.activation.as_str() { + "missing" => Value::Null, + "invalid" => json!("invalid"), + "before" => json!("2025-12-31T23:59:59.999999999Z"), + "equal" => json!("2026-01-01T00:00:00Z"), + "after" => json!("2026-01-01T00:00:00.000000001Z"), + _ => panic!("unknown activation: {}", case.id), + }; + let config = configuration(flag(activation, case.args.do_log)); + let result = evaluate(&config, "feature", mode, &case.args.attribute); + match case.expected.error.as_str() { + "INVALID_CONTEXT" => assert_eq!( + result.unwrap_err(), + EvaluationError::InvalidContext, + "{}", + case.id + ), + "PARSE_ERROR" => assert_eq!( + result.unwrap_err(), + EvaluationError::FlagConfigurationInvalid, + "{}", + case.id + ), + "NONE" => { + let assignment = result.unwrap_or_else(|e| panic!("{}: {e}", case.id)); + assert_eq!( + assignment.allocation_key.as_str(), + if case.expected.exclude { + "fallback" + } else { + "candidate" + }, + "{}", + case.id + ); + assert_eq!( + assignment.value.variation_value(), + json!(if case.expected.exclude { + "ordinary" + } else { + "experiment" + }), + "{}", + case.id + ); + assert_eq!( + assignment.variation_key.as_str(), + if case.expected.exclude { + "ordinary" + } else { + "experiment" + }, + "{}", + case.id + ); + assert_eq!( + assignment.serial_id, + Some(if case.expected.exclude { 8 } else { 7 }), + "{}", + case.id + ); + assert_eq!( + assignment.do_log, + !case.expected.exclude && case.args.do_log, + "{}", + case.id + ); + } + _ => panic!("unknown expected error: {}", case.id), + } + count += 1; + } + assert_eq!(count, 90, "complete Lean policy profile must execute"); + eprintln!("Lean policy: {count} passed, 0 failed, 0 skipped"); +} + +#[test] +fn activation_validation_covers_unreachable_and_ordinary_allocations() { + // D004 applies to the entire flag, even after a matching allocation or behind time/rule gates. + for malformed in [ + json!("invalid"), + json!(true), + json!(42), + json!([]), + json!({}), + ] { + for position in [ + "after-match", + "unmatched-rule", + "future-start", + "expired", + "ordinary", + ] { + let mut wire = flag(malformed.clone(), position != "ordinary"); + match position { + "after-match" => wire["allocations"].as_array_mut().unwrap().swap(0, 1), + "unmatched-rule" => { + wire["allocations"][0]["rules"] = json!([{"conditions": [{"attribute": "missing", "operator": "ONE_OF", "value": ["required"]}]}]) + } + "future-start" => wire["allocations"][0]["startAt"] = json!("9999-01-01T00:00:00Z"), + "expired" => wire["allocations"][0]["endAt"] = json!("2000-01-01T00:00:00Z"), + _ => {} + } + let config = configuration(wire); + for mode in MODES { + for attribute in ["absent", "valid", "invalid"] { + let result = evaluate(&config, "feature", mode, attribute); + if mode != MODES[0] && attribute == "invalid" { + assert_eq!(result.unwrap_err(), EvaluationError::InvalidContext); + } else if mode == MODES[2] && attribute == "valid" { + assert_eq!( + result.unwrap_err(), + EvaluationError::FlagConfigurationInvalid, + "{position}: {malformed}" + ); + } else { + assert!( + result.is_ok(), + "{mode:?}/{attribute}/{position}: {malformed}: {result:?}" + ); + } + if attribute != "invalid" || mode == MODES[0] { + assert!(evaluate(&config, "neighbor", mode, attribute).is_ok()); + } + } + } + } + } +} + +#[test] +fn start_and_end_validity_remain_independent_of_opt_out() { + for field in ["startAt", "endAt"] { + let mut wire = flag(Value::Null, false); + wire["allocations"][0][field] = json!("invalid"); + let config = configuration(wire); + for mode in MODES { + for attribute in ["absent", "valid"] { + assert_eq!( + evaluate(&config, "feature", mode, attribute).unwrap_err(), + EvaluationError::FlagConfigurationInvalid + ); + assert!(evaluate(&config, "neighbor", mode, attribute).is_ok()); + } + } + } +} + +#[test] +fn missing_and_null_activation_have_the_same_conservative_behavior() { + for missing in [true, false] { + let mut wire = flag(Value::Null, true); + if missing { + wire["allocations"][0] + .as_object_mut() + .unwrap() + .remove("randomizationActivatedAt"); + } + let config = configuration(wire); + let result = evaluate(&config, "feature", MODES[2], "valid").unwrap(); + assert_eq!(result.allocation_key.as_str(), "fallback"); + assert!(!result.do_log); + } +} + +#[test] +fn opt_out_timestamps_reject_surrounding_whitespace() { + for timestamp in [ + " 2026-01-01T00:00:00Z", + "2026-01-01T00:00:00Z ", + "\t2026-01-01T00:00:00Z", + "2026-01-01T00:00:00Z\n", + "\u{2003}2026-01-01T00:00:00Z", + ] { + let config = configuration(flag(json!(timestamp), true)); + for mode in MODES { + let subject = EvaluationContext::new( + Some("subject".into()), + Arc::new(HashMap::from([( + "dd_experimentation_opt_out_at".into(), + timestamp.into(), + )])), + ); + let result = config.eval_flag_with_options( + "missing", + &subject, + ExpectedFlagType::String, + Utc::now(), + EvaluationOptions { + experimentation_opt_out_mode: mode, + }, + ); + assert_eq!( + result.unwrap_err(), + if mode == MODES[0] { + EvaluationError::FlagUnrecognizedOrDisabled + } else { + EvaluationError::InvalidContext + } + ); + let activation = evaluate(&config, "feature", mode, "valid"); + if mode == MODES[2] { + assert_eq!( + activation.unwrap_err(), + EvaluationError::FlagConfigurationInvalid + ); + } else { + assert!(activation.is_ok()); + } + assert!(evaluate(&config, "feature", mode, "absent").is_ok()); + assert!(evaluate(&config, "neighbor", mode, "valid").is_ok()); + } + } +} diff --git a/libdd-ffe/README.md b/libdd-ffe/README.md index 5004893cf6..25d913061d 100644 --- a/libdd-ffe/README.md +++ b/libdd-ffe/README.md @@ -6,6 +6,7 @@ This is an internal DataDog library that supports Feature Flag evaluation. It is The caller selects `EvaluationOptions.experimentation_opt_out_mode`; the evaluator reads no environment variables. Both enabled modes require a valid `dd_experimentation_opt_out_at` timestamp when the attribute is present. +Cutoff and activation timestamps reject surrounding whitespace and otherwise use the existing Chrono timestamp grammar. Missing input preserves normal evaluation. Invalid input returns `InvalidContext` before flag lookup. | Mode | Exposure-logging allocations | @@ -16,7 +17,8 @@ Missing input preserves normal evaluation. Invalid input returns `InvalidContext Future mode uses `randomizationActivatedAt`, with nanosecond precision and timezone-aware comparisons. Older allocations remain eligible; this does not prove previous enrollment. -Malformed activation rejects the affected flag in every mode. Missing or null activation is unknown. +Malformed activation rejects the affected flag only in future mode with a valid subject cutoff, including unreachable allocations. +Other modes and subjects without a cutoff do not require valid activation. Missing or null activation is unknown. Skipped allocations produce no exposure. Ordinary allocations remain available, and evaluation telemetry is unchanged. The C API adds `ddog_ffe_get_assignment_with_opt_out_mode`. diff --git a/libdd-ffe/src/rules_based/eval/eval_assignment.rs b/libdd-ffe/src/rules_based/eval/eval_assignment.rs index ec8bb2d5bc..ae3321ae63 100644 --- a/libdd-ffe/src/rules_based/eval/eval_assignment.rs +++ b/libdd-ffe/src/rules_based/eval/eval_assignment.rs @@ -2,11 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 use chrono::{DateTime, Utc}; -use serde::{Deserialize, de::value::StrDeserializer}; use crate::rules_based::{ Configuration, EvaluationContext, ExpectedFlagType, Timestamp, error::EvaluationError, + timestamp::OptOutTimestamp, ufc::{Allocation, Assignment, AssignmentReason, CompiledFlagsConfig, Flag, Shard, Split}, }; @@ -37,8 +37,15 @@ enum ExperimentationExclusion { 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; } @@ -66,10 +73,9 @@ impl EvaluationOptions { let Some(timestamp) = attribute.as_str() else { return Err(EvaluationError::InvalidContext); }; - // Use the same timestamp deserializer as allocation startAt and endAt. - let cutoff = - Timestamp::deserialize(StrDeserializer::::new(timestamp)) - .map_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, @@ -184,6 +190,12 @@ impl Flag { now: DateTime, exclusion: ExperimentationExclusion, ) -> Result { + // D004: flag-wide activation validity is consumed only by effective future policy. + 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, @@ -210,7 +222,7 @@ impl Flag { exclusion: ExperimentationExclusion, ) -> Result<(&Allocation, &Split, AssignmentReason), EvaluationError> { for allocation in &self.allocations { - if exclusion.excludes(allocation) { + if exclusion != ExperimentationExclusion::None && exclusion.excludes(allocation) { continue; } if let Some((split, reason)) = allocation.get_matching_split(context, now)? { @@ -355,7 +367,7 @@ mod opt_out_tests { } #[test] - fn opt_out_uses_allocation_timestamp_contract() { + fn opt_out_preserves_existing_timestamp_syntax_without_surrounding_whitespace() { let immediate = EvaluationOptions { experimentation_opt_out_mode: ExperimentationOptOutMode::Immediate, }; @@ -532,7 +544,42 @@ mod opt_out_tests { } #[test] - fn malformed_activation_is_isolated_in_every_mode() { + 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 [ @@ -543,30 +590,40 @@ mod opt_out_tests { let options = EvaluationOptions { experimentation_opt_out_mode: mode, }; - let subject = context(Some(json!("2026-10-19T12:00:00Z"))); - assert_eq!( - config - .eval_flag_with_options( - "feature", - &subject, - ExpectedFlagType::Boolean, - Utc::now(), - options, - ) - .unwrap_err(), - EvaluationError::FlagConfigurationInvalid - ); - assert!( - config - .eval_flag_with_options( - "neighbor", - &subject, - ExpectedFlagType::Boolean, - Utc::now(), - options, - ) - .is_ok() - ); + 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() + ); + } } } } diff --git a/libdd-ffe/src/rules_based/timestamp.rs b/libdd-ffe/src/rules_based/timestamp.rs index f549418a35..0c7f9b1abf 100644 --- a/libdd-ffe/src/rules_based/timestamp.rs +++ b/libdd-ffe/src/rules_based/timestamp.rs @@ -3,9 +3,35 @@ //! A simple wrapper around `chrono`, so users don’t have to depend on it directly. use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize, de::value::StrDeserializer}; 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 { + // D005's surrounding-whitespace rule applies to opt-out timestamps only. + // Other syntax retains the existing Chrono timestamp grammar. + if value.trim() != value { + return Err("invalid opt-out timestamp"); + } + Timestamp::deserialize(StrDeserializer::::new(value)) + .map(Self) + .map_err(|_| "invalid opt-out 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 dda6357e39..7cd52181a8 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)] @@ -39,6 +39,8 @@ pub(crate) struct CompiledFlagsConfig { pub(crate) struct Flag { 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)] @@ -129,6 +131,12 @@ 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 allocations = flag .allocations .into_iter() @@ -138,6 +146,7 @@ fn compile_flag(flag: FlagWire) -> Result { Ok(Flag { variation_type: flag.variation_type, allocations, + has_invalid_activation, }) } @@ -154,7 +163,12 @@ fn compile_allocation( key: allocation.key, start_at: allocation.start_at, end_at: allocation.end_at, - randomization_activated_at: allocation.randomization_activated_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 34d97f48e7..198612fba3 100644 --- a/libdd-ffe/src/rules_based/ufc/models.rs +++ b/libdd-ffe/src/rules_based/ufc/models.rs @@ -6,6 +6,7 @@ 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. @@ -156,7 +157,7 @@ pub(crate) struct AllocationWire { #[serde(default)] pub end_at: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub randomization_activated_at: Option, + pub randomization_activated_at: Option>, pub splits: Vec, #[serde(default = "default_do_log")] pub do_log: bool, From 975c8e4fe8004f2bc84be2c5f93ead909946720c Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 26 Oct 2026 19:38:00 +0000 Subject: [PATCH 3/3] fix(ffe): enforce the shared opt-out timestamp grammar Use one strict parser for cutoffs and activation while preserving lowercase timestamps, nanosecond truncation, and existing allocation scheduling behavior. --- libdd-ffe-test-suite/tests/data/README.md | 19 +++ .../experimentation-opt-out-timestamps.jsonl | 50 ++++++ .../tests/experimentation_opt_out.rs | 147 +++++++++++++++++- libdd-ffe/README.md | 8 +- .../src/rules_based/eval/eval_assignment.rs | 38 ++--- libdd-ffe/src/rules_based/timestamp.rs | 78 ++++++++-- 6 files changed, 306 insertions(+), 34 deletions(-) create mode 100644 libdd-ffe-test-suite/tests/data/experimentation-opt-out-timestamps.jsonl diff --git a/libdd-ffe-test-suite/tests/data/README.md b/libdd-ffe-test-suite/tests/data/README.md index d9b976369c..5ac6cefa4a 100644 --- a/libdd-ffe-test-suite/tests/data/README.md +++ b/libdd-ffe-test-suite/tests/data/README.md @@ -9,3 +9,22 @@ Extracted policy SHA256: `0d396beb2b627606f795386340d5e9d7443a40c51e84200b605279 The cases cover D002, D004 revision 2, D010 for valid boolean input, and D011. The adapter uses the production Rust evaluator. It preserves every expected result. Timestamp grammar, provider configuration, transport, and telemetry delivery are outside this profile. + +## Timestamp cases + +`experimentation-opt-out-timestamps.jsonl` contains all 50 timestamp cases from +the [pinned shared corpus](https://github.com/DataDog/ffe-system-test-data/blob/59ab2bfcb9a2480762bf51451a3316e093247bd4/contracts/experimentation-opt-out/corpus/browser-proposal.jsonl), +including the seven exact examples in +[libdatadog #2698's parser review](https://github.com/DataDog/libdatadog/pull/2698#discussion_r4380967749). +The additional cases extend that corpus without changing its existing expectations. +The shared contract remains a [draft PR](https://github.com/DataDog/ffe-system-test-data/pull/55). + +Extended shared-corpus SHA256: `4c4628ae30217bd2ead2ef34144f43e0163f9916ff0c8accae0847391dff64a6`. +Extracted timestamp SHA256: `f09e4061d624b196cc51d9c3936428621ca7a8b79f7a0ad1cc993f6b57bdf31c`. + +The 50 cases cover D005 revision 2, D006 revision 2, and D007 revision 1. +The production evaluator checks both cutoff and activation parsing. Valid inputs +are compared with the oracle's expected instant and its adjacent representable +nanoseconds, verifying normalization and strict cutoff selection. Invalid inputs +retain their mode-specific errors; null is invalid subject input but missing +activation. No case is excluded. Existing `startAt`/`endAt` syntax is separate. diff --git a/libdd-ffe-test-suite/tests/data/experimentation-opt-out-timestamps.jsonl b/libdd-ffe-test-suite/tests/data/experimentation-opt-out-timestamps.jsonl new file mode 100644 index 0000000000..cd994332bb --- /dev/null +++ b/libdd-ffe-test-suite/tests/data/experimentation-opt-out-timestamps.jsonl @@ -0,0 +1,50 @@ +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 0, "seconds": 1767225600}, "id": "timestamp/utc", "input": "2026-01-01T00:00:00Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 0, "seconds": 1767225600}, "id": "timestamp/lower-t", "input": "2026-01-01t00:00:00Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 0, "seconds": 1767225600}, "id": "timestamp/lower-z", "input": "2026-01-01T00:00:00z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 0, "seconds": 1767225600}, "id": "timestamp/lower-both", "input": "2026-01-01t00:00:00z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 123456789, "seconds": 1792411200}, "id": "timestamp/nanos", "input": "2026-10-19T12:00:00.123456789Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 123456789, "seconds": 1792411200}, "id": "timestamp/ten-digits-zero", "input": "2026-10-19T12:00:00.1234567890Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 123456789, "seconds": 1792411200}, "id": "timestamp/ten-digits-nine", "input": "2026-10-19T12:00:00.1234567899Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 123456789, "seconds": 1792411200}, "id": "timestamp/twelve-digits", "input": "2026-10-19T12:00:00.123456789999Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 0, "seconds": 1767225600}, "id": "timestamp/fraction-zero", "input": "2026-01-01T00:00:00.000000000000Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 100000000, "seconds": 1767225600}, "id": "timestamp/fraction-one", "input": "2026-01-01T00:00:00.1Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 123456789, "seconds": 1792411200}, "id": "timestamp/offset-negative", "input": "2026-10-19T08:00:00.123456789-04:00", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 123456789, "seconds": 1792411200}, "id": "timestamp/offset-positive", "input": "2026-10-19T14:00:00.123456789+02:00", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 0, "seconds": 1767225600}, "id": "timestamp/offset-negative-zero", "input": "2026-01-01T00:00:00-00:00", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 0, "seconds": 1767139260}, "id": "timestamp/offset-max", "input": "2026-01-01T00:00:00+23:59", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/space", "input": "2026-01-01 00:00:00Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/comma", "input": "2026-01-01T00:00:00,123Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/leap-second", "input": "2016-12-31T23:59:60Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/unicode-minus", "input": "2026-01-01T00:00:00\u221205:00", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/offset-compact", "input": "2026-01-01T00:00:00+0000", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/named-zone", "input": "2026-01-01T00:00:00 UTC", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/offset-hour24", "input": "2026-01-01T00:00:00+24:00", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/offset-minute60", "input": "2026-01-01T00:00:00+01:60", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/one-digit-hour", "input": "2026-01-01T0:00:00Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 0, "seconds": -62167219200}, "id": "timestamp/year-zero", "input": "0000-01-01T00:00:00Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/utc-year-underflow", "input": "0000-01-01T00:00:00+01:00", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 999999999, "seconds": 253402300799}, "id": "timestamp/year-max", "input": "9999-12-31T23:59:59.999999999Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/utc-year-overflow", "input": "9999-12-31T23:59:59-01:00", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/julian-leap", "input": "1500-02-29T00:00:00Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 0, "seconds": -12219724800}, "id": "timestamp/gregorian-cutover", "input": "1582-10-10T00:00:00Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 0, "seconds": 951782400}, "id": "timestamp/leap-2000", "input": "2000-02-29T00:00:00Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/nonleap-1900", "input": "1900-02-29T00:00:00Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/bad-month", "input": "2026-13-01T00:00:00Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/bad-day", "input": "2026-04-31T00:00:00Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/hour24", "input": "2026-01-01T24:00:00Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/empty-fraction", "input": "2026-01-01T00:00:00.Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/leading-space", "input": " 2026-01-01T00:00:00Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/trailing-space", "input": "2026-01-01T00:00:00Z ", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/trailing-newline", "input": "2026-01-01T00:00:00Z\n", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/no-zone", "input": "2026-01-01T00:00:00", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/empty", "input": "", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/null", "input": null, "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 0, "seconds": 0}, "id": "timestamp/epoch", "input": "1970-01-01T00:00:00Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 999999999, "seconds": -1}, "id": "timestamp/pre-epoch-nanos", "input": "1969-12-31T23:59:59.999999999Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 0, "seconds": 1792497600}, "id": "timestamp/libdatadog-2698/utc", "input": "2026-10-20T12:00:00Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/libdatadog-2698/space", "input": "2026-10-20 12:00:00Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": true, "nanos": 0, "seconds": 1792497600}, "id": "timestamp/libdatadog-2698/lowercase", "input": "2026-10-20t12:00:00z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/libdatadog-2698/short-fields", "input": "2026-1-5T1:2:3Z", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/libdatadog-2698/named-zone", "input": "2026-10-20T12:00:00UTC", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/libdatadog-2698/compact-offset", "input": "2026-10-20T12:00:00+0000", "kind": "timestamp"} +{"decision_ids": ["D005", "D006", "D007"], "expected": {"accepted": false}, "id": "timestamp/libdatadog-2698/internal-spaces", "input": "2026 - 10 - 20T12:00:00Z", "kind": "timestamp"} diff --git a/libdd-ffe-test-suite/tests/experimentation_opt_out.rs b/libdd-ffe-test-suite/tests/experimentation_opt_out.rs index ddf9c0e3a0..d6c890ed4e 100644 --- a/libdd-ffe-test-suite/tests/experimentation_opt_out.rs +++ b/libdd-ffe-test-suite/tests/experimentation_opt_out.rs @@ -3,7 +3,7 @@ use std::{collections::HashMap, sync::Arc}; -use chrono::Utc; +use chrono::{DateTime, Datelike, SecondsFormat, TimeDelta, Utc}; use libdd_ffe::rules_based::{ Assignment, Attribute, Configuration, EvaluationContext, EvaluationError, EvaluationOptions, ExpectedFlagType, ExperimentationOptOutMode, Str, UniversalFlagConfig, @@ -281,6 +281,22 @@ fn missing_and_null_activation_have_the_same_conservative_behavior() { } } +#[test] +fn opt_out_grammar_does_not_narrow_allocation_schedules() { + for (field, timestamp) in [ + ("startAt", "2000-01-01 00:00:00Z"), + ("endAt", "9999-01-01 00:00:00Z"), + ] { + let mut wire = flag(Value::Null, false); + wire["allocations"][0][field] = json!(timestamp); + let config = configuration(wire); + for mode in MODES { + let assignment = evaluate(&config, "feature", mode, "valid").unwrap(); + assert_eq!(assignment.allocation_key.as_str(), "candidate", "{field}"); + } + } +} + #[test] fn opt_out_timestamps_reject_surrounding_whitespace() { for timestamp in [ @@ -330,3 +346,132 @@ fn opt_out_timestamps_reject_surrounding_whitespace() { } } } + +#[derive(Deserialize)] +struct TimestampCase { + id: String, + input: Value, + expected: TimestampResult, +} + +#[derive(Deserialize)] +struct TimestampResult { + accepted: bool, + seconds: Option, + nanos: Option, +} + +fn evaluate_timestamp( + config: &Configuration, + cutoff: &Value, + mode: ExperimentationOptOutMode, +) -> Result { + let subject = EvaluationContext::new( + Some("subject".into()), + Arc::new(HashMap::from([( + "dd_experimentation_opt_out_at".into(), + serde_json::from_value(cutoff.clone()).unwrap(), + )])), + ); + config.eval_flag_with_options( + "feature", + &subject, + ExpectedFlagType::String, + Utc::now(), + EvaluationOptions { + experimentation_opt_out_mode: mode, + }, + ) +} + +fn assert_timestamp_assignment(result: Assignment, retained: bool, id: &str) { + assert_eq!( + result.allocation_key.as_str(), + if retained { "candidate" } else { "fallback" }, + "{id}" + ); + assert_eq!(result.do_log, retained, "{id}"); + assert_eq!(result.serial_id, Some(if retained { 7 } else { 8 }), "{id}"); +} + +#[test] +fn lean_timestamp_cases_use_both_production_paths() { + // D005/D006/D007: frozen expected instants, never the host parser, are the oracle. + let cases: Vec = include_str!("data/experimentation-opt-out-timestamps.jsonl") + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(cases.len(), 50, "complete timestamp profile must execute"); + let mut evaluations = 0; + for case in &cases { + let activation_config = configuration(flag(case.input.clone(), true)); + if case.expected.accepted { + let instant = DateTime::from_timestamp( + case.expected.seconds.unwrap(), + case.expected.nanos.unwrap(), + ) + .unwrap(); + for delta in [-1, 0, 1] { + let adjacent = instant + TimeDelta::nanoseconds(delta); + // The UTC boundary itself is covered; its out-of-domain neighbor is not a + // valid reference timestamp. Every corpus case still executes both paths. + if !(0..=9999).contains(&adjacent.year()) { + continue; + } + let reference = json!(adjacent.to_rfc3339_opts(SecondsFormat::Nanos, true)); + let cutoff_config = configuration(flag(reference.clone(), true)); + assert_timestamp_assignment( + evaluate_timestamp(&cutoff_config, &case.input, MODES[2]) + .unwrap_or_else(|error| panic!("{} cutoff: {error}", case.id)), + delta < 0, + &case.id, + ); + assert_timestamp_assignment( + evaluate_timestamp(&activation_config, &reference, MODES[2]) + .unwrap_or_else(|error| panic!("{} activation: {error}", case.id)), + delta > 0, + &case.id, + ); + evaluations += 2; + } + } else { + let valid_config = configuration(flag(json!("2025-01-01T00:00:00Z"), true)); + for mode in MODES { + let cutoff = evaluate_timestamp(&valid_config, &case.input, mode); + if mode == MODES[0] { + assert_timestamp_assignment(cutoff.unwrap(), true, &case.id); + } else { + assert_eq!( + cutoff.err(), + Some(EvaluationError::InvalidContext), + "{} cutoff: {mode:?}", + case.id + ); + } + let activation = evaluate(&activation_config, "feature", mode, "valid"); + if mode == MODES[2] && !case.input.is_null() { + assert_eq!( + activation.err(), + Some(EvaluationError::FlagConfigurationInvalid), + "{} activation", + case.id + ); + } else { + // Null remains missing activation, even though it is invalid subject input. + assert_timestamp_assignment(activation.unwrap(), mode == MODES[0], &case.id); + } + assert_timestamp_assignment( + evaluate(&activation_config, "feature", mode, "absent").unwrap(), + true, + &case.id, + ); + assert!(evaluate(&activation_config, "neighbor", mode, "valid").is_ok()); + evaluations += 4; + } + } + } + eprintln!( + "Lean timestamp profile: {} cases, {evaluations} evaluations, 0 failed, 0 excluded cases", + cases.len() + ); +} diff --git a/libdd-ffe/README.md b/libdd-ffe/README.md index 25d913061d..288e369279 100644 --- a/libdd-ffe/README.md +++ b/libdd-ffe/README.md @@ -6,7 +6,13 @@ This is an internal DataDog library that supports Feature Flag evaluation. It is The caller selects `EvaluationOptions.experimentation_opt_out_mode`; the evaluator reads no environment variables. Both enabled modes require a valid `dd_experimentation_opt_out_at` timestamp when the attribute is present. -Cutoff and activation timestamps reject surrounding whitespace and otherwise use the existing Chrono timestamp grammar. +Cutoff and activation timestamps use `YYYY-MM-DD[Tt]HH:mm:ss[.fraction](Z|z|±HH:mm)` +with ASCII digits and valid Gregorian calendar fields. Seconds are `00`–`59`; +numeric offset hours are `00`–`23` and minutes are `00`–`59`. +Fractions contain at least one digit and truncate after nine digits without rounding. +Both the written year and the normalized UTC year must be `0000`–`9999`. +Whitespace, named zones, compact offsets, and leap seconds are rejected. +The existing `startAt`/`endAt` parsing is unchanged. Missing input preserves normal evaluation. Invalid input returns `InvalidContext` before flag lookup. | Mode | Exposure-logging allocations | diff --git a/libdd-ffe/src/rules_based/eval/eval_assignment.rs b/libdd-ffe/src/rules_based/eval/eval_assignment.rs index ae3321ae63..7778da9ad4 100644 --- a/libdd-ffe/src/rules_based/eval/eval_assignment.rs +++ b/libdd-ffe/src/rules_based/eval/eval_assignment.rs @@ -367,34 +367,26 @@ mod opt_out_tests { } #[test] - fn opt_out_preserves_existing_timestamp_syntax_without_surrounding_whitespace() { + fn opt_out_timestamp_syntax_is_explicit_and_errors_are_redacted() { let immediate = EvaluationOptions { experimentation_opt_out_mode: ExperimentationOptOutMode::Immediate, }; - for timestamp in [ - "2026-10-19T12:00:00Z", - "2099-01-01T00:00:00.123456789+05:30", - "2026-10-19 12:00:00Z", - "2026-10-19t12:00:00z", - "2016-12-31T23:59:60Z", - "2026-10-19T1:00:00Z", - "2026-10-19T12:00:00,123Z", - "2026-10-19T12:00:00+24:00", - "2026-02-30T00:00:00Z", - "2026-10-19T12:00:00", - "private-invalid-timestamp", - "", + 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 allocation = - serde_json::from_value::(json!({ - "key": "test", - "startAt": timestamp, - "endAt": timestamp, - "splits": [], - "doLog": true, - })); let result = immediate.experimentation_exclusion(&context(Some(json!(timestamp)))); - let expected = if allocation.is_ok() { + let expected = if accepted { Ok(ExperimentationExclusion::Immediate) } else { Err(EvaluationError::InvalidContext) diff --git a/libdd-ffe/src/rules_based/timestamp.rs b/libdd-ffe/src/rules_based/timestamp.rs index 0c7f9b1abf..d1ee61bafc 100644 --- a/libdd-ffe/src/rules_based/timestamp.rs +++ b/libdd-ffe/src/rules_based/timestamp.rs @@ -2,8 +2,8 @@ // 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 serde::{Deserialize, Serialize, de::value::StrDeserializer}; +use chrono::{DateTime, Datelike, NaiveDate, TimeDelta, Utc}; +use serde::{Deserialize, Serialize}; pub type Timestamp = DateTime; @@ -14,15 +14,75 @@ pub(crate) struct OptOutTimestamp(pub Timestamp); impl OptOutTimestamp { pub fn parse(value: &str) -> Result { - // D005's surrounding-whitespace rule applies to opt-out timestamps only. - // Other syntax retains the existing Chrono timestamp grammar. - if value.trim() != value { - return Err("invalid opt-out timestamp"); - } - Timestamp::deserialize(StrDeserializer::::new(value)) + parse_opt_out_timestamp(value) .map(Self) - .map_err(|_| "invalid opt-out timestamp") + .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 {