diff --git a/crates/context_aware_config/src/api/context.rs b/crates/context_aware_config/src/api/context.rs index 0d6cee04d..719154423 100644 --- a/crates/context_aware_config/src/api/context.rs +++ b/crates/context_aware_config/src/api/context.rs @@ -1,3 +1,4 @@ +pub mod auto_reduce; mod handlers; pub mod helpers; pub mod operations; diff --git a/crates/context_aware_config/src/api/context/auto_reduce.rs b/crates/context_aware_config/src/api/context/auto_reduce.rs new file mode 100644 index 000000000..129d9a7a2 --- /dev/null +++ b/crates/context_aware_config/src/api/context/auto_reduce.rs @@ -0,0 +1,175 @@ +use serde_json::{Map, Value}; +use service_utils::service::types::{SchemaName, WorkspaceContext}; +use superposition_core::{config::eval, helpers::hash}; +use superposition_macros::unexpected_error; +use superposition_types::{ + Cac, Condition, Config, DBConnection, Overrides, api::config::MergeStrategy, + database::models::cac::Context as DbContext, result as superposition, +}; + +use crate::helpers::generate_cac; + +const VARIANT_IDS_DIMENSION: &str = "variantIds"; + +/// Config snapshot to check redundancy against. Loads the whole workspace, so build it once per request. +pub struct AutoReducer { + config: Config, +} + +pub struct Reduction { + pub kept: Map, + pub dropped: Vec, +} + +impl Reduction { + pub fn is_fully_redundant(&self) -> bool { + self.kept.is_empty() && !self.dropped.is_empty() + } +} + +impl AutoReducer { + pub fn from_config(config: Config) -> Self { + Self { config } + } + + pub fn new( + conn: &mut DBConnection, + schema_name: &SchemaName, + ) -> superposition::Result { + Ok(Self { + config: generate_cac(conn, schema_name)?, + }) + } + + /// Drops keys `condition` already resolves to under MERGE, the strategy + /// resolution uses. `context_id` is excluded, else it would match itself. + pub fn reduce( + &self, + context_id: &str, + condition: &Condition, + overrides: &Overrides, + ) -> Reduction { + let query_data: Map = condition.clone().into_inner(); + + let contexts: Vec<_> = self + .config + .contexts + .iter() + .filter(|ctx| ctx.id != context_id) + .cloned() + .collect(); + + let resolved = eval( + self.config.default_configs.clone(), + &contexts, + &self.config.overrides, + &self.config.dimensions, + query_data, + MergeStrategy::MERGE, + None, + None, + ); + + let mut kept = Map::new(); + let mut dropped = Vec::new(); + + for (key, value) in overrides.clone().into_inner() { + if resolved.get(&key) == Some(&value) { + dropped.push(key); + } else { + kept.insert(key, value); + } + } + + dropped.sort(); + Reduction { kept, dropped } + } +} + +/// The `x-auto-reduce` header wins; without it, the workspace setting applies. +pub fn is_enabled(header: Option, workspace_context: &WorkspaceContext) -> bool { + header.unwrap_or(workspace_context.settings.enable_auto_reduce) +} + +pub fn build_if_enabled( + enabled: bool, + conn: &mut DBConnection, + schema_name: &SchemaName, +) -> superposition::Result> { + if !enabled { + return Ok(None); + } + AutoReducer::new(conn, schema_name).map(Some) +} + +pub enum ReducedContext { + Unchanged(DbContext), + Trimmed { + context: DbContext, + dropped: Vec, + }, + /// Every key was redundant, so there is nothing to write. + FullyRedundant { + context: DbContext, + dropped: Vec, + }, +} + +/// Run auto-reduce over a fully built context that is about to be written. +pub fn apply( + reducer: Option<&AutoReducer>, + context: DbContext, + schema_name: &SchemaName, +) -> superposition::Result { + let Some(reducer) = reducer else { + return Ok(ReducedContext::Unchanged(context)); + }; + + // Experiments pin these by id and override_id; trimming one breaks them. + if context.value.contains_key(VARIANT_IDS_DIMENSION) { + return Ok(ReducedContext::Unchanged(context)); + } + + let reduction = reducer.reduce(&context.id, &context.value, &context.override_); + if reduction.dropped.is_empty() { + return Ok(ReducedContext::Unchanged(context)); + } + + if reduction.is_fully_redundant() { + log::info!( + "auto_reduce[{}]: context {} is fully redundant, skipping write; dropped keys: {:?}", + schema_name.0, + context.id, + reduction.dropped + ); + return Ok(ReducedContext::FullyRedundant { + context, + dropped: reduction.dropped, + }); + } + + log::info!( + "auto_reduce[{}]: dropping redundant keys {:?} from context {}", + schema_name.0, + reduction.dropped, + context.id + ); + + let mut context = context; + // override_id is what resolution looks the override up by. + context.override_id = hash(&Value::Object(reduction.kept.clone())); + context.override_ = Cac::::try_from(reduction.kept) + .map_err(|err| { + log::error!("auto_reduce: reduced overrides rejected: {err}"); + unexpected_error!(err) + })? + .into_inner(); + + Ok(ReducedContext::Trimmed { + context, + dropped: reduction.dropped, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/context_aware_config/src/api/context/auto_reduce/tests.rs b/crates/context_aware_config/src/api/context/auto_reduce/tests.rs new file mode 100644 index 000000000..37d7b9ef1 --- /dev/null +++ b/crates/context_aware_config/src/api/context/auto_reduce/tests.rs @@ -0,0 +1,362 @@ +//! Tests for auto-reduce. + +use std::collections::HashMap; + +use bigdecimal::BigDecimal; +use chrono::Utc; +use serde_json::{Map, Value, json}; +use service_utils::service::types::{OrganisationId, WorkspaceId}; +use superposition_types::{ + Cac, Condition, Config, Context, ExtendedMap, OverrideWithKeys, Overrides, + database::models::{ + ChangeReason, Description, Metrics, NonEmptyString, Workspace, WorkspaceStatus, + cac::Context as DbContext, + }, +}; + +use super::*; + +// helpers + +fn value_map(values: Vec<(&str, Value)>) -> Map { + values + .into_iter() + .map(|(key, value)| (key.to_string(), value)) + .collect() +} + +fn condition(values: Vec<(&str, Value)>) -> Condition { + Cac::::try_from(value_map(values)) + .unwrap() + .into_inner() +} + +fn overrides(values: Vec<(&str, Value)>) -> Overrides { + Cac::::try_from(value_map(values)) + .unwrap() + .into_inner() +} + +/// List contexts lowest weight first, the order resolution applies them in. +fn context(id: &str, conditions: Vec<(&str, Value)>, override_key: &str) -> Context { + Context { + id: id.to_string(), + condition: condition(conditions), + priority: 0, + weight: 0, + override_with_keys: OverrideWithKeys::new(override_key.to_string()), + } +} + +fn config( + contexts: Vec, + override_entries: Vec<(&str, Vec<(&str, Value)>)>, + defaults: Vec<(&str, Value)>, +) -> Config { + let overrides_map: HashMap = override_entries + .into_iter() + .map(|(key, values)| (key.to_string(), overrides(values))) + .collect(); + Config { + contexts, + overrides: overrides_map, + default_configs: ExtendedMap::from(value_map(defaults)), + dimensions: HashMap::new(), + } +} + +fn reducer( + contexts: Vec, + override_entries: Vec<(&str, Vec<(&str, Value)>)>, + defaults: Vec<(&str, Value)>, +) -> AutoReducer { + AutoReducer::from_config(config(contexts, override_entries, defaults)) +} + +fn db_context( + id: &str, + conditions: Vec<(&str, Value)>, + override_values: Vec<(&str, Value)>, +) -> DbContext { + let now = Utc::now(); + DbContext { + id: id.to_string(), + value: condition(conditions), + override_id: "override-id".to_string(), + created_at: now, + created_by: "test@superposition.io".to_string(), + override_: overrides(override_values), + last_modified_at: now, + last_modified_by: "test@superposition.io".to_string(), + weight: BigDecimal::from(1), + description: Description::try_from("test".to_string()).unwrap(), + change_reason: ChangeReason::try_from("test".to_string()).unwrap(), + } +} + +fn schema() -> SchemaName { + SchemaName("test".to_string()) +} + +// reduce + +#[test] +fn drops_a_key_a_broader_context_already_resolves_to_the_same_value() { + let reducer = reducer( + vec![context("broad", vec![("os", json!("android"))], "o1")], + vec![("o1", vec![("colour", json!("red"))])], + vec![("colour", json!("blue"))], + ); + + let reduction = reducer.reduce( + "new", + &condition(vec![("os", json!("android")), ("city", json!("bangalore"))]), + &overrides(vec![("colour", json!("red"))]), + ); + + assert_eq!(reduction.dropped, vec!["colour".to_string()]); + assert!(reduction.kept.is_empty()); + assert!(reduction.is_fully_redundant()); +} + +#[test] +fn keeps_a_key_that_differs_from_what_the_condition_resolves_to() { + let reducer = reducer( + vec![context("broad", vec![("os", json!("android"))], "o1")], + vec![("o1", vec![("colour", json!("red"))])], + vec![("colour", json!("blue"))], + ); + + let reduction = reducer.reduce( + "new", + &condition(vec![("os", json!("android")), ("city", json!("bangalore"))]), + &overrides(vec![("colour", json!("green"))]), + ); + + assert!(reduction.dropped.is_empty()); + assert_eq!(reduction.kept, value_map(vec![("colour", json!("green"))])); + assert!(!reduction.is_fully_redundant()); +} + +#[test] +fn drops_a_key_that_only_restates_the_default_config() { + let reducer = reducer(vec![], vec![], vec![("colour", json!("blue"))]); + + let reduction = reducer.reduce( + "new", + &condition(vec![("os", json!("android"))]), + &overrides(vec![("colour", json!("blue"))]), + ); + + assert_eq!(reduction.dropped, vec!["colour".to_string()]); + assert!(reduction.is_fully_redundant()); +} + +#[test] +fn keeps_only_the_redundant_half_of_a_mixed_override() { + let reducer = reducer( + vec![context("broad", vec![("os", json!("android"))], "o1")], + vec![("o1", vec![("colour", json!("red"))])], + vec![("colour", json!("blue")), ("size", json!(10))], + ); + + let reduction = reducer.reduce( + "new", + &condition(vec![("os", json!("android")), ("city", json!("bangalore"))]), + &overrides(vec![("colour", json!("red")), ("size", json!(42))]), + ); + + assert_eq!(reduction.dropped, vec!["colour".to_string()]); + assert_eq!(reduction.kept, value_map(vec![("size", json!(42))])); + assert!(!reduction.is_fully_redundant()); +} + +#[test] +fn a_context_is_excluded_from_the_resolution_that_judges_it() { + // Left in, "self" would be the highest-weight exact match and match itself. + let reducer = reducer( + vec![context("self", vec![("os", json!("android"))], "o1")], + vec![("o1", vec![("colour", json!("red"))])], + vec![("colour", json!("blue"))], + ); + + let reduction = reducer.reduce( + "self", + &condition(vec![("os", json!("android"))]), + &overrides(vec![("colour", json!("red"))]), + ); + + assert!(reduction.dropped.is_empty()); + assert_eq!(reduction.kept, value_map(vec![("colour", json!("red"))])); +} + +#[test] +fn keeps_a_key_whose_object_value_merges_into_a_broader_one() { + // MERGE resolves flags to {"a":1,"b":2}, so an incoming {"b":2} is not redundant. + let reducer = reducer( + vec![ + context("low", vec![("os", json!("android"))], "o1"), + context( + "high", + vec![("os", json!("android")), ("city", json!("bangalore"))], + "o2", + ), + ], + vec![ + ("o1", vec![("flags", json!({"a": 1}))]), + ("o2", vec![("flags", json!({"b": 2}))]), + ], + vec![("flags", json!({}))], + ); + + let reduction = reducer.reduce( + "new", + &condition(vec![ + ("os", json!("android")), + ("city", json!("bangalore")), + ("tier", json!("t1")), + ]), + &overrides(vec![("flags", json!({"b": 2}))]), + ); + + assert!( + reduction.dropped.is_empty(), + "a key that is load-bearing under MERGE must survive" + ); + assert_eq!(reduction.kept, value_map(vec![("flags", json!({"b": 2}))])); +} + +// apply + +#[test] +fn apply_skips_variant_contexts() { + // Trimming would leave the experiment pointing at a stale override. + let reducer = reducer(vec![], vec![], vec![("colour", json!("blue"))]); + let ctx = db_context( + "variant-ctx", + vec![("os", json!("android")), ("variantIds", json!("variant-1"))], + vec![("colour", json!("blue"))], + ); + let original = ctx.override_.clone(); + + match apply(Some(&reducer), ctx, &schema()).unwrap() { + ReducedContext::Unchanged(ctx) => assert_eq!(ctx.override_, original), + _ => panic!("variant contexts must never be reduced"), + } +} + +#[test] +fn apply_trims_the_override_and_rehashes_the_override_id() { + let reducer = reducer( + vec![context("broad", vec![("os", json!("android"))], "o1")], + vec![("o1", vec![("colour", json!("red"))])], + vec![("colour", json!("blue")), ("size", json!(10))], + ); + let ctx = db_context( + "new", + vec![("os", json!("android")), ("city", json!("bangalore"))], + vec![("colour", json!("red")), ("size", json!(42))], + ); + let stale_override_id = ctx.override_id.clone(); + + match apply(Some(&reducer), ctx, &schema()).unwrap() { + ReducedContext::Trimmed { context, dropped } => { + assert_eq!(dropped, vec!["colour".to_string()]); + assert_eq!( + context.override_, + overrides(vec![("size", json!(42))]), + "the redundant key must be gone from what gets written" + ); + assert_ne!( + context.override_id, stale_override_id, + "override_id is what resolution looks the override up by, so it \ + must be re-hashed for the trimmed map" + ); + assert_eq!( + context.override_id, + superposition_core::helpers::hash(&Value::Object(value_map(vec![( + "size", + json!(42) + )]))) + ); + } + _ => panic!("expected the context to be trimmed"), + } +} + +#[test] +fn apply_reports_a_fully_redundant_context_without_touching_it() { + let reducer = reducer( + vec![context("broad", vec![("os", json!("android"))], "o1")], + vec![("o1", vec![("colour", json!("red"))])], + vec![("colour", json!("blue"))], + ); + let ctx = db_context( + "new", + vec![("os", json!("android")), ("city", json!("bangalore"))], + vec![("colour", json!("red"))], + ); + + match apply(Some(&reducer), ctx, &schema()).unwrap() { + ReducedContext::FullyRedundant { context, dropped } => { + assert_eq!(dropped, vec!["colour".to_string()]); + assert_eq!( + context.override_, + overrides(vec![("colour", json!("red"))]), + "the reported context keeps what was submitted; it is just not written" + ); + } + _ => panic!("expected the context to be reported as fully redundant"), + } +} + +// is_enabled + +fn workspace_context(setting: bool) -> WorkspaceContext { + let now = Utc::now(); + WorkspaceContext { + workspace_id: WorkspaceId("test".to_string()), + organisation_id: OrganisationId("org".to_string()), + schema_name: schema(), + settings: Workspace { + organisation_id: "org".to_string(), + organisation_name: NonEmptyString::try_from("org".to_string()).unwrap(), + workspace_name: "test".to_string(), + workspace_schema_name: "test".to_string(), + workspace_status: WorkspaceStatus::ENABLED, + workspace_admin_email: "test@superposition.io".to_string(), + config_version: None, + created_by: "test@superposition.io".to_string(), + last_modified_by: "test@superposition.io".to_string(), + last_modified_at: now, + created_at: now, + mandatory_dimensions: None, + metrics: Metrics::default(), + allow_experiment_self_approval: false, + auto_populate_control: false, + enable_context_validation: false, + enable_change_reason_validation: false, + enable_auto_reduce: setting, + encryption_key: String::new(), + key_rotated_at: None, + workspace_lock_id: None, + workspace_lock_operation: None, + workspace_locked_by: None, + workspace_lock_acquired_at: None, + workspace_lock_expires_at: None, + }, + } +} + +#[test] +fn no_header_falls_back_to_the_workspace_setting() { + assert!(is_enabled(None, &workspace_context(true))); + assert!(!is_enabled(None, &workspace_context(false))); +} + +#[test] +fn the_header_overrides_the_workspace_setting_both_ways() { + // What experimentation relies on: header false wins over a workspace set true. + assert!(!is_enabled(Some(false), &workspace_context(true))); + assert!(is_enabled(Some(true), &workspace_context(false))); +} diff --git a/crates/context_aware_config/src/api/context/handlers.rs b/crates/context_aware_config/src/api/context/handlers.rs index 09f4a0cc6..ffb9995ee 100644 --- a/crates/context_aware_config/src/api/context/handlers.rs +++ b/crates/context_aware_config/src/api/context/handlers.rs @@ -33,8 +33,9 @@ use superposition_types::{ DimensionMatchStrategy, context::{ BulkOperation, BulkOperationResponse, ContextAction, ContextBulkResponse, - ContextListFilters, ContextValidationRequest, Identifier, MoveRequest, - PutRequest, SortOn, UpdateRequest, WeightRecomputeResponse, + ContextListFilters, ContextValidationRequest, ContextWithDroppedKeys, + Identifier, MoveRequest, PutRequest, SortOn, UpdateRequest, + WeightRecomputeResponse, }, webhook::Action, }, @@ -52,6 +53,7 @@ use superposition_types::{ use crate::{ api::context::{ + auto_reduce, helpers::{ changed_keys, create_ctx_from_put_req, query_description, validate_ctx, validate_override_with_functions, @@ -149,6 +151,24 @@ async fn create_handler( ) .await?; + let reducer = auto_reduce::build_if_enabled( + auto_reduce::is_enabled(custom_headers.auto_reduce, &workspace_context), + conn, + &workspace_context.schema_name, + )?; + let (new_ctx, dropped_keys) = match auto_reduce::apply( + reducer.as_ref(), + new_ctx, + &workspace_context.schema_name, + )? { + auto_reduce::ReducedContext::Unchanged(ctx) => (ctx, Vec::new()), + auto_reduce::ReducedContext::Trimmed { context, dropped } => (context, dropped), + // Nothing written, so no body and no config version bump; dropped keys are logged. + auto_reduce::ReducedContext::FullyRedundant { .. } => { + return Ok(HttpResponse::NoContent().finish()); + } + }; + let (put_response, config_version) = conn .transaction::<_, superposition::AppError, _>(|transaction_conn| { let put_response = operations::upsert( @@ -207,7 +227,7 @@ async fn create_handler( config_version.id.to_string(), )); - Ok(http_resp.json(put_response)) + Ok(http_resp.json(ContextWithDroppedKeys::new(put_response, dropped_keys))) } async fn update_authorized( @@ -818,6 +838,12 @@ enum PreparedOperation { Put { new_ctx: Context, change_reason: ChangeReason, + dropped_keys: Vec, + }, + /// Fully redundant PUT: keeps its slot in the response but writes nothing. + PutNoOp { + new_ctx: Context, + dropped_keys: Vec, }, Replace { update_request: UpdateRequest, @@ -861,6 +887,14 @@ async fn bulk_operations_handler( let mut webhook_contexts: Vec = Vec::new(); let tags = parse_config_tags(custom_headers.config_tags)?; + + // Snapshots pre-batch state, like the rest of Phase 1. + let reducer = auto_reduce::build_if_enabled( + auto_reduce::is_enabled(custom_headers.auto_reduce, &workspace_context), + conn, + &workspace_context.schema_name, + )?; + // ── Phase 1: async validation & preparation ── let mut prepared_ops = Vec::with_capacity(if ops.len() > 100 { 100 } else { ops.len() }); @@ -899,10 +933,33 @@ async fn bulk_operations_handler( ) .await?; - prepared_ops.push(PreparedOperation::Put { + let prepared = match auto_reduce::apply( + reducer.as_ref(), new_ctx, - change_reason, - }); + &workspace_context.schema_name, + )? { + auto_reduce::ReducedContext::Unchanged(new_ctx) => { + PreparedOperation::Put { + new_ctx, + change_reason, + dropped_keys: Vec::new(), + } + } + auto_reduce::ReducedContext::Trimmed { context, dropped } => { + PreparedOperation::Put { + new_ctx: context, + change_reason, + dropped_keys: dropped, + } + } + auto_reduce::ReducedContext::FullyRedundant { context, dropped } => { + PreparedOperation::PutNoOp { + new_ctx: context, + dropped_keys: dropped, + } + } + }; + prepared_ops.push(prepared); } ContextAction::Replace(update_request) => { let change_reason = update_request.change_reason.clone(); @@ -975,6 +1032,40 @@ async fn bulk_operations_handler( } } + // An all-redundant batch writes nothing, so no version bump and no webhook. + if !prepared_ops.is_empty() + && prepared_ops + .iter() + .all(|op| matches!(op, PreparedOperation::PutNoOp { .. })) + { + let response: Vec = prepared_ops + .into_iter() + .map(|op| match op { + PreparedOperation::PutNoOp { + new_ctx, + dropped_keys, + } => ContextBulkResponse::Put(ContextWithDroppedKeys::new( + new_ctx, + dropped_keys, + )), + _ => unreachable!("guarded by the all() above"), + }) + .collect(); + + let mut resp_builder = HttpResponse::Ok(); + if let Some(version) = workspace_context.settings.config_version { + resp_builder.insert_header(( + AppHeader::XConfigVersion.to_string(), + version.to_string(), + )); + } + return Ok(if is_v2 { + resp_builder.json(BulkOperationResponse { output: response }) + } else { + resp_builder.json(response) + }); + } + // ── Phase 2: single transaction for all DB writes ── let (response, config_version) = conn.transaction::<_, superposition::AppError, _>(|transaction_conn| { @@ -983,9 +1074,18 @@ async fn bulk_operations_handler( for prepared in prepared_ops { match prepared { + PreparedOperation::PutNoOp { + new_ctx, + dropped_keys, + } => { + response.push(ContextBulkResponse::Put( + ContextWithDroppedKeys::new(new_ctx, dropped_keys), + )); + } PreparedOperation::Put { new_ctx, change_reason, + dropped_keys, } => { let put_resp = operations::upsert( transaction_conn, @@ -1006,7 +1106,9 @@ async fn bulk_operations_handler( all_change_reasons.push(change_reason); webhook_contexts.push(put_resp.clone()); webhook_actions.push(Action::Create); - response.push(ContextBulkResponse::Put(put_resp)); + response.push(ContextBulkResponse::Put( + ContextWithDroppedKeys::new(put_resp, dropped_keys), + )); } PreparedOperation::Replace { update_request, diff --git a/crates/experimentation_platform/src/api/experiments/handlers.rs b/crates/experimentation_platform/src/api/experiments/handlers.rs index 3a2a31044..e3541dd8b 100644 --- a/crates/experimentation_platform/src/api/experiments/handlers.rs +++ b/crates/experimentation_platform/src/api/experiments/handlers.rs @@ -316,9 +316,11 @@ async fn create_handler( ) })?; + // Variant overrides are the variant definition; reducing them empties the control. let extra_headers = vec![ ("x-user", Some(user_str)), ("x-config-tags", custom_headers.config_tags), + ("x-auto-reduce", Some("false".to_string())), ] .into_iter() .filter_map(|(key, val)| val.map(|v| (key, v))) @@ -357,8 +359,8 @@ async fn create_handler( for i in 0..created_contexts.len() { let created_context = &created_contexts[i]; - variants[i].context_id = Some(created_context.id.clone()); - variants[i].override_id = Some(created_context.override_id.clone()); + variants[i].context_id = Some(created_context.context.id.clone()); + variants[i].override_id = Some(created_context.context.override_id.clone()); } let now = Utc::now(); @@ -686,10 +688,15 @@ pub async fn conclude( err ) })?; - let extra_headers = vec![("x-user", Some(user_str)), ("x-config-tags", config_tags)] - .into_iter() - .filter_map(|(key, val)| val.map(|v| (key, v))) - .collect::>(); + // Variant overrides are the variant definition; reducing them empties the control. + let extra_headers = vec![ + ("x-user", Some(user_str)), + ("x-config-tags", config_tags), + ("x-auto-reduce", Some("false".to_string())), + ] + .into_iter() + .filter_map(|(key, val)| val.map(|v| (key, v))) + .collect::>(); let headers_map = construct_header_map(workspace_context, extra_headers)?; @@ -860,10 +867,15 @@ pub async fn discard( ) })?; - let extra_headers = vec![("x-user", Some(user_str)), ("x-config-tags", config_tags)] - .into_iter() - .filter_map(|(key, val)| val.map(|v| (key, v))) - .collect::>(); + // Variant overrides are the variant definition; reducing them empties the control. + let extra_headers = vec![ + ("x-user", Some(user_str)), + ("x-config-tags", config_tags), + ("x-auto-reduce", Some("false".to_string())), + ] + .into_iter() + .filter_map(|(key, val)| val.map(|v| (key, v))) + .collect::>(); let headers_map = construct_header_map(workspace_context, extra_headers)?; @@ -1789,9 +1801,11 @@ async fn update_handler( err ) })?; + // Variant overrides are the variant definition; reducing them empties the control. let extra_headers = vec![ ("x-user", Some(user_str)), ("x-config-tags", custom_headers.config_tags), + ("x-auto-reduce", Some("false".to_string())), ] .into_iter() .filter_map(|(key, val)| val.map(|v| (key, v))) diff --git a/crates/frontend/src/api.rs b/crates/frontend/src/api.rs index a89ef8566..b1b1ff9d6 100644 --- a/crates/frontend/src/api.rs +++ b/crates/frontend/src/api.rs @@ -775,6 +775,7 @@ pub mod workspaces { auto_populate_control: bool, enable_context_validation: bool, enable_change_reason_validation: bool, + enable_auto_reduce: bool, ) -> Result { Ok(UpdateWorkspaceRequest { workspace_admin_email: Some(workspace_admin_email), @@ -789,6 +790,7 @@ pub mod workspaces { auto_populate_control: Some(auto_populate_control), enable_context_validation: Some(enable_context_validation), enable_change_reason_validation: Some(enable_change_reason_validation), + enable_auto_reduce: Some(enable_auto_reduce), }) } diff --git a/crates/frontend/src/components/workspace_form.rs b/crates/frontend/src/components/workspace_form.rs index bb4f93548..4440a5dc2 100644 --- a/crates/frontend/src/components/workspace_form.rs +++ b/crates/frontend/src/components/workspace_form.rs @@ -33,6 +33,7 @@ pub fn WorkspaceForm( #[prop(default = true)] auto_populate_control: bool, #[prop(default = false)] enable_context_validation: bool, #[prop(default = false)] enable_change_reason_validation: bool, + #[prop(default = false)] enable_auto_reduce: bool, #[prop(into)] handle_submit: Callback<(), ()>, ) -> impl IntoView { let (workspace_name_rs, workspace_name_ws) = create_signal(workspace_name); @@ -51,6 +52,8 @@ pub fn WorkspaceForm( create_signal(enable_context_validation); let (enable_change_reason_validation_rs, enable_change_reason_validation_ws) = create_signal(enable_change_reason_validation); + let (enable_auto_reduce_rs, enable_auto_reduce_ws) = + create_signal(enable_auto_reduce); let on_submit = move |ev: MouseEvent| { req_inprogress_ws.set(true); @@ -70,6 +73,7 @@ pub fn WorkspaceForm( auto_populate_control_rs.get_untracked(), enable_context_validation_rs.get_untracked(), enable_change_reason_validation_rs.get_untracked(), + enable_auto_reduce_rs.get_untracked(), ); match update_payload { Ok(payload) => { @@ -95,6 +99,7 @@ pub fn WorkspaceForm( .get_untracked(), enable_change_reason_validation: enable_change_reason_validation_rs.get_untracked(), + enable_auto_reduce: enable_auto_reduce_rs.get_untracked(), }; workspaces::create(create_payload, &org_id.get_untracked().0).await }; @@ -259,6 +264,18 @@ pub fn WorkspaceForm( /> +
+ +
+ impl IntoView { .as_bool() .unwrap_or_default(); + let enable_auto_reduce = + row["enable_auto_reduce"].as_bool().unwrap_or_default(); + let workspace_name_clone = workspace_name.clone(); let edit_click_handler = move |_| { @@ -118,6 +121,7 @@ pub fn Workspace() -> impl IntoView { auto_populate_control, enable_context_validation, enable_change_reason_validation, + enable_auto_reduce, }; logging::log!("{:?}", row_data); selected_workspace.set(Some(row_data)); @@ -220,6 +224,7 @@ pub fn Workspace() -> impl IntoView { .enable_context_validation enable_change_reason_validation=selected_workspace_data .enable_change_reason_validation + enable_auto_reduce=selected_workspace_data.enable_auto_reduce handle_submit=move |_| { workspace_resource.refetch(); selected_workspace.set(None); diff --git a/crates/service_utils/src/service/types.rs b/crates/service_utils/src/service/types.rs index cf5a30883..3c067e3af 100644 --- a/crates/service_utils/src/service/types.rs +++ b/crates/service_utils/src/service/types.rs @@ -380,6 +380,8 @@ impl FromRequest for WorkspaceWritePermit { pub struct CustomHeaders { pub config_tags: Option, pub idempotency_key: Option, + /// `x-auto-reduce`. `None` means fall back to the workspace setting. + pub auto_reduce: Option, } impl FromRequest for CustomHeaders { type Error = Error; @@ -399,6 +401,18 @@ impl FromRequest for CustomHeaders { .and_then(|v| v.to_str().ok()) .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()), + auto_reduce: header_val + .get("x-auto-reduce") + .and_then(|v| v.to_str().ok()) + .and_then(|v| match v.trim().parse::() { + Ok(parsed) => Some(parsed), + Err(_) => { + log::warn!( + "ignoring x-auto-reduce: expected true/false, got {v:?}" + ); + None + } + }), }; ready(Ok(val)) } diff --git a/crates/superposition/src/workspace/handlers.rs b/crates/superposition/src/workspace/handlers.rs index f00df8c31..120aa6871 100644 --- a/crates/superposition/src/workspace/handlers.rs +++ b/crates/superposition/src/workspace/handlers.rs @@ -156,6 +156,7 @@ async fn create_handler( auto_populate_control: request.auto_populate_control, enable_context_validation: request.enable_context_validation, enable_change_reason_validation: request.enable_change_reason_validation, + enable_auto_reduce: request.enable_auto_reduce, encryption_key, key_rotated_at: None, workspace_lock_id: None, diff --git a/crates/superposition_types/migrations/2026-08-28-000000_workspace_enable_auto_reduce/down.sql b/crates/superposition_types/migrations/2026-08-28-000000_workspace_enable_auto_reduce/down.sql new file mode 100644 index 000000000..1e8d362a1 --- /dev/null +++ b/crates/superposition_types/migrations/2026-08-28-000000_workspace_enable_auto_reduce/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE superposition.workspaces +DROP COLUMN IF EXISTS enable_auto_reduce; diff --git a/crates/superposition_types/migrations/2026-08-28-000000_workspace_enable_auto_reduce/up.sql b/crates/superposition_types/migrations/2026-08-28-000000_workspace_enable_auto_reduce/up.sql new file mode 100644 index 000000000..d161c5b31 --- /dev/null +++ b/crates/superposition_types/migrations/2026-08-28-000000_workspace_enable_auto_reduce/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE superposition.workspaces +ADD COLUMN IF NOT EXISTS enable_auto_reduce BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/crates/superposition_types/src/api/context.rs b/crates/superposition_types/src/api/context.rs index 011275040..bbb4dce88 100644 --- a/crates/superposition_types/src/api/context.rs +++ b/crates/superposition_types/src/api/context.rs @@ -98,10 +98,34 @@ pub enum ContextAction { Move { id: String, request: MoveRequest }, } +/// Flattened, and `dropped_keys` is omitted when empty, so the old payload shape is unchanged. +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct ContextWithDroppedKeys { + #[serde(flatten)] + pub context: Context, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub dropped_keys: Vec, +} + +impl ContextWithDroppedKeys { + pub fn new(context: Context, dropped_keys: Vec) -> Self { + Self { + context, + dropped_keys, + } + } +} + +impl From for ContextWithDroppedKeys { + fn from(context: Context) -> Self { + Self::new(context, Vec::new()) + } +} + #[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "UPPERCASE")] pub enum ContextBulkResponse { - Put(Context), + Put(ContextWithDroppedKeys), Replace(Context), Delete(String), Move(Context), @@ -124,3 +148,77 @@ pub struct BulkOperation { pub struct BulkOperationResponse { pub output: Vec, } + +#[cfg(test)] +mod tests { + use bigdecimal::BigDecimal; + use chrono::Utc; + use serde_json::{json, Value}; + + use super::*; + use crate::database::models::{ChangeReason, Description}; + + fn sample_context() -> Context { + let now = Utc::now(); + Context { + id: "ctx-1".to_string(), + value: Cac::::try_from( + json!({ "os": "android" }).as_object().unwrap().clone(), + ) + .unwrap() + .into_inner(), + override_id: "ovr-1".to_string(), + created_at: now, + created_by: "test@superposition.io".to_string(), + override_: Cac::::try_from( + json!({ "colour": "red" }).as_object().unwrap().clone(), + ) + .unwrap() + .into_inner(), + last_modified_at: now, + last_modified_by: "test@superposition.io".to_string(), + weight: "12345678901234567890.000000000001" + .parse::() + .unwrap(), + description: Description::try_from("d".to_string()).unwrap(), + change_reason: ChangeReason::try_from("c".to_string()).unwrap(), + } + } + + /// `flatten` goes through `deserialize_any`, where `BigDecimal` tends to break. + #[test] + fn context_with_dropped_keys_round_trips() { + let original = ContextWithDroppedKeys::new( + sample_context(), + vec!["a".to_string(), "b".to_string()], + ); + + let encoded = serde_json::to_string(&original).unwrap(); + let decoded: ContextWithDroppedKeys = serde_json::from_str(&encoded).unwrap(); + + assert_eq!(decoded.context.id, original.context.id); + assert_eq!(decoded.context.weight, original.context.weight); + assert_eq!(decoded.context.created_at, original.context.created_at); + assert_eq!(decoded.context.override_, original.context.override_); + assert_eq!(decoded.dropped_keys, vec!["a".to_string(), "b".to_string()]); + } + + #[test] + fn no_dropped_keys_serialises_to_the_plain_context_shape() { + let context = sample_context(); + let wrapped: ContextWithDroppedKeys = context.clone().into(); + + let wrapped_json: Value = serde_json::to_value(&wrapped).unwrap(); + let plain_json: Value = serde_json::to_value(&context).unwrap(); + + assert_eq!(wrapped_json, plain_json); + assert!(wrapped_json.get("dropped_keys").is_none()); + } + + #[test] + fn a_payload_without_dropped_keys_still_parses() { + let plain = serde_json::to_string(&sample_context()).unwrap(); + let decoded: ContextWithDroppedKeys = serde_json::from_str(&plain).unwrap(); + assert!(decoded.dropped_keys.is_empty()); + } +} diff --git a/crates/superposition_types/src/api/workspace.rs b/crates/superposition_types/src/api/workspace.rs index 3fe21262f..59803160c 100644 --- a/crates/superposition_types/src/api/workspace.rs +++ b/crates/superposition_types/src/api/workspace.rs @@ -30,6 +30,7 @@ pub struct WorkspaceResponse { pub auto_populate_control: bool, pub enable_context_validation: bool, pub enable_change_reason_validation: bool, + pub enable_auto_reduce: bool, pub workspace_lock: Option, } @@ -97,6 +98,7 @@ impl From for WorkspaceResponse { auto_populate_control: workspace.auto_populate_control, enable_context_validation: workspace.enable_context_validation, enable_change_reason_validation: workspace.enable_change_reason_validation, + enable_auto_reduce: workspace.enable_auto_reduce, workspace_lock, } } @@ -116,6 +118,8 @@ pub struct CreateWorkspaceRequest { pub enable_context_validation: bool, #[serde(default)] pub enable_change_reason_validation: bool, + #[serde(default)] + pub enable_auto_reduce: bool, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -132,6 +136,7 @@ pub struct UpdateWorkspaceRequest { pub auto_populate_control: Option, pub enable_context_validation: Option, pub enable_change_reason_validation: Option, + pub enable_auto_reduce: Option, } #[derive(Deserialize, Debug, IsEmpty, QueryParam, Default, Clone)] diff --git a/crates/superposition_types/src/database/models.rs b/crates/superposition_types/src/database/models.rs index c6544ba24..d170629ca 100644 --- a/crates/superposition_types/src/database/models.rs +++ b/crates/superposition_types/src/database/models.rs @@ -279,6 +279,7 @@ pub struct Workspace { pub auto_populate_control: bool, pub enable_context_validation: bool, pub enable_change_reason_validation: bool, + pub enable_auto_reduce: bool, pub encryption_key: String, pub key_rotated_at: Option>, pub workspace_lock_id: Option, diff --git a/crates/superposition_types/src/database/superposition_schema.rs b/crates/superposition_types/src/database/superposition_schema.rs index 5eff9abe9..d5ee4e632 100644 --- a/crates/superposition_types/src/database/superposition_schema.rs +++ b/crates/superposition_types/src/database/superposition_schema.rs @@ -60,6 +60,7 @@ pub mod superposition { auto_populate_control -> Bool, enable_context_validation -> Bool, enable_change_reason_validation -> Bool, + enable_auto_reduce -> Bool, encryption_key -> Text, key_rotated_at -> Nullable, workspace_lock_id -> Nullable, diff --git a/smithy/models/context.smithy b/smithy/models/context.smithy index f392b171e..257afdc84 100644 --- a/smithy/models/context.smithy +++ b/smithy/models/context.smithy @@ -6,6 +6,11 @@ list OverrideWithKeys { member: String } +@documentation("Override keys that auto-reduce removed because the context's own condition already resolved them to the same value.") +list DroppedKeys { + member: String +} + resource Context { identifiers: { workspace_id: String @@ -73,6 +78,45 @@ structure ContextResponse for Context { @required $last_modified_by + + @documentation("Populated only by CreateContext and BulkOperation PUT when the workspace has enable_auto_reduce set. Lists the override keys that were dropped as redundant. Absent or empty means nothing was dropped.") + @notProperty + dropped_keys: DroppedKeys +} + +@documentation("Response to CreateContext. Every member is optional because a create whose overrides were all dropped by auto-reduce writes nothing and answers 204 with an empty body; on 200 every member except dropped_keys is present.") +structure CreateContextResponse { + @documentation("200 when the context was written, 204 when auto-reduce found every override key redundant and nothing was written.") + @httpResponseCode + @notProperty + status: Integer + + @notProperty + id: String + + value: Condition + + override: Overrides + + override_id: String + + weight: Weight + + description: String + + change_reason: String + + created_at: DateTime + + created_by: String + + last_modified_at: DateTime + + last_modified_by: String + + @documentation("Override keys auto-reduce dropped as redundant. Absent or empty means nothing was dropped.") + @notProperty + dropped_keys: DroppedKeys } @documentation("Creates a new context with specified conditions and overrides. Contexts define conditional rules for config management.") @@ -85,13 +129,18 @@ operation CreateContext with [GetOperation, WebhookOperation, WorkspaceWriteOper @notProperty config_tags: String + @documentation("Overrides the workspace's enable_auto_reduce setting for this request. Omit to use the workspace setting.") + @httpHeader("x-auto-reduce") + @notProperty + auto_reduce: Boolean + @httpPayload @notProperty @required request: ContextPut } - output: ContextResponse + output: CreateContextResponse } @documentation("Validates if a given context condition is well-formed") @@ -359,6 +408,11 @@ operation BulkOperation with [GetOperation, WebhookOperation, WorkspaceWriteOper @notProperty config_tags: String + @documentation("Overrides the workspace's enable_auto_reduce setting for this request. Omit to use the workspace setting.") + @httpHeader("x-auto-reduce") + @notProperty + auto_reduce: Boolean + @required @notProperty operations: BulkOperationList diff --git a/smithy/models/workspace.smithy b/smithy/models/workspace.smithy index 43ad8da0c..decf305f6 100644 --- a/smithy/models/workspace.smithy +++ b/smithy/models/workspace.smithy @@ -24,6 +24,7 @@ resource Workspace { auto_populate_control: Boolean enable_context_validation: Boolean enable_change_reason_validation: Boolean + enable_auto_reduce: Boolean workspace_lock: WorkspaceLock } list: ListWorkspace @@ -86,6 +87,8 @@ structure CreateWorkspaceRequest for Workspace with [OrganisationMixin] { $enable_context_validation $enable_change_reason_validation + + $enable_auto_reduce } structure UpdateWorkspaceRequest for Workspace with [OrganisationMixin] { @@ -111,6 +114,8 @@ structure UpdateWorkspaceRequest for Workspace with [OrganisationMixin] { $enable_context_validation $enable_change_reason_validation + + $enable_auto_reduce } structure WorkspaceSelectorRequest for Workspace with [OrganisationMixin] { @@ -169,6 +174,9 @@ structure WorkspaceResponse for Workspace { @required $enable_change_reason_validation + @required + $enable_auto_reduce + $workspace_lock } diff --git a/superposition.sql b/superposition.sql index 6c643229f..eeca9d0fe 100644 --- a/superposition.sql +++ b/superposition.sql @@ -148,6 +148,9 @@ ALTER TABLE superposition.workspaces ADD COLUMN IF NOT EXISTS encryption_key TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS key_rotated_at TIMESTAMPTZ; +ALTER TABLE superposition.workspaces +ADD COLUMN IF NOT EXISTS enable_auto_reduce BOOLEAN NOT NULL DEFAULT FALSE; + ALTER TABLE superposition.workspaces ADD COLUMN IF NOT EXISTS workspace_lock_id UUID, ADD COLUMN IF NOT EXISTS workspace_lock_operation TEXT,