Skip to main content

dialcache/
policy.rs

1//! Static operation policy, sparse runtime overlays and per-invocation resolution.
2
3use serde_json::{Map, Value};
4
5use crate::limits::{DEFAULT_REMOTE_READ_TIMEOUT_MS, MAX_CACHE_TTL_SEC, MAX_DEADLINE_MS};
6use crate::observe::DisabledReason;
7
8/// Per-use-case shadow validation policy.
9#[derive(Debug, Clone, PartialEq, Default)]
10pub struct ShadowPolicy {
11    /// Independent stable cohort percentage in `[0, 100]`. Omitted and zero disable shadow work.
12    pub ramp: Option<f64>,
13    /// Shorthand for the whole [`mismatch_logging`](Self::mismatch_logging)
14    /// group: `true` stands for `value` on with [`MismatchDiff::Values`],
15    /// `false` for both leaves off. Leaves set in `mismatch_logging` override
16    /// what it expands to, before runtime overlays merge leaf by leaf.
17    pub log_mismatches: Option<bool>,
18    /// Content of the warning emitted per confirmed mismatch. Omitted leaves
19    /// default off.
20    pub mismatch_logging: Option<MismatchLogging>,
21}
22
23/// Content of the one warning emitted per confirmed shadow mismatch. Both
24/// leaves default off, and the warning is emitted when either is on.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub struct MismatchLogging {
27    /// Log the JSON of both compared values. Defaults to false.
28    pub value: Option<bool>,
29    /// How much of the structural difference to log. Defaults to
30    /// [`MismatchDiff::Off`].
31    pub diff: Option<MismatchDiff>,
32}
33
34/// How much of the structural difference a confirmed-mismatch warning logs.
35/// The levels are ordered: each logs everything the previous one does.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
37pub enum MismatchDiff {
38    /// No difference fields.
39    #[default]
40    Off,
41    /// Where the values differ: `mismatchPaths` and `mismatchPathCount`.
42    Paths,
43    /// The paths plus what each side holds there: `diffJson`.
44    Values,
45}
46
47impl MismatchDiff {
48    /// The level as the JSON configuration spells it: `off`, `paths` or `values`.
49    pub fn as_str(self) -> &'static str {
50        match self {
51            MismatchDiff::Off => "off",
52            MismatchDiff::Paths => "paths",
53            MismatchDiff::Values => "values",
54        }
55    }
56
57    fn from_json(value: &Value) -> Option<MismatchDiff> {
58        match value.as_str()? {
59            "off" => Some(MismatchDiff::Off),
60            "paths" => Some(MismatchDiff::Paths),
61            "values" => Some(MismatchDiff::Values),
62            _ => None,
63        }
64    }
65}
66
67impl std::fmt::Display for MismatchDiff {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.write_str(self.as_str())
70    }
71}
72
73/// Static caching policy of one operation. Omitted leaves use library
74/// defaults or are overridden by a runtime overlay.
75#[derive(Debug, Clone, PartialEq, Default)]
76pub struct Policy {
77    /// Memoize values for the outermost enabled scope. Defaults to false.
78    pub request_local: Option<bool>,
79    /// Share one execution across concurrent same-key callers. Defaults to true.
80    pub coalesce: Option<bool>,
81    /// Process-local TTL in whole seconds, `1..=MAX_CACHE_TTL_SEC`. Omitted disables the layer.
82    pub local_ttl_sec: Option<u64>,
83    /// Remote TTL in whole seconds. Omitted disables the layer.
84    pub remote_ttl_sec: Option<u64>,
85    /// Local serving cohort percentage; a configured TTL implies 100.
86    pub local_ramp: Option<f64>,
87    /// Remote serving cohort percentage; a configured TTL implies 100.
88    pub remote_ramp: Option<f64>,
89    /// Exclusive stale-recovery age ceiling in seconds; must exceed the remote TTL. Zero disables.
90    pub stale_on_error_max_age_sec: Option<u64>,
91    /// Remote read budget in milliseconds, `1..=MAX_DEADLINE_MS`.
92    pub remote_read_timeout_ms: Option<u64>,
93    /// Shadow validation cohort and warning policy. Omitted means no shadow work.
94    pub shadow: Option<ShadowPolicy>,
95}
96
97impl Policy {
98    /// Both serving layers enabled with the same TTL and full ramps.
99    pub fn enabled(ttl_sec: u64) -> Self {
100        Policy {
101            local_ttl_sec: Some(ttl_sec),
102            remote_ttl_sec: Some(ttl_sec),
103            local_ramp: Some(100.0),
104            remote_ramp: Some(100.0),
105            ..Policy::default()
106        }
107    }
108
109    /// The explicit kill switch: request memoization, recovery and shadow
110    /// work off, both serving layers ramped to zero. As an overlay it disables
111    /// every inherited path instead of relying on omission.
112    pub fn disabled() -> Self {
113        Policy {
114            request_local: Some(false),
115            stale_on_error_max_age_sec: Some(0),
116            local_ramp: Some(0.0),
117            remote_ramp: Some(0.0),
118            shadow: Some(ShadowPolicy {
119                ramp: Some(0.0),
120                log_mismatches: None,
121                mismatch_logging: Some(MismatchLogging {
122                    value: Some(false),
123                    diff: Some(MismatchDiff::Off),
124                }),
125            }),
126            ..Policy::default()
127        }
128    }
129
130    /// Set whether values are memoized for the outermost enabled scope.
131    pub fn request_local(mut self, enabled: bool) -> Self {
132        self.request_local = Some(enabled);
133        self
134    }
135
136    /// Set whether concurrent same-key callers share one execution.
137    pub fn coalesce(mut self, enabled: bool) -> Self {
138        self.coalesce = Some(enabled);
139        self
140    }
141
142    /// Set the process-local TTL in whole seconds, enabling the layer with
143    /// an implied full ramp.
144    pub fn local_ttl_sec(mut self, ttl_sec: u64) -> Self {
145        self.local_ttl_sec = Some(ttl_sec);
146        self
147    }
148
149    /// Set the remote TTL in whole seconds, enabling the layer with an
150    /// implied full ramp.
151    pub fn remote_ttl_sec(mut self, ttl_sec: u64) -> Self {
152        self.remote_ttl_sec = Some(ttl_sec);
153        self
154    }
155
156    /// Set the process-local serving cohort percentage, `0.0..=100.0`.
157    pub fn local_ramp(mut self, ramp: f64) -> Self {
158        self.local_ramp = Some(ramp);
159        self
160    }
161
162    /// Set the remote serving cohort percentage, `0.0..=100.0`.
163    pub fn remote_ramp(mut self, ramp: f64) -> Self {
164        self.remote_ramp = Some(ramp);
165        self
166    }
167
168    /// Set the stale-recovery age ceiling in seconds. Zero disables
169    /// recovery; a positive value must exceed the remote TTL.
170    pub fn stale_on_error_max_age_sec(mut self, seconds: u64) -> Self {
171        self.stale_on_error_max_age_sec = Some(seconds);
172        self
173    }
174
175    /// Set the remote read budget in milliseconds, overriding the instance default.
176    pub fn remote_read_timeout_ms(mut self, ms: u64) -> Self {
177        self.remote_read_timeout_ms = Some(ms);
178        self
179    }
180
181    /// Set the shadow validation policy.
182    pub fn shadow(mut self, shadow: ShadowPolicy) -> Self {
183        self.shadow = Some(shadow);
184        self
185    }
186
187    /// Parse the JSON-shaped TypeScript configuration
188    /// (`ttlSec`, `ramp`, `shadow`, `requestLocal`, `coalesce`,
189    /// `staleOnErrorMaxAgeSec`, `remoteReadTimeoutMs`). `null` or absent means an empty policy.
190    ///
191    /// A present leaf holding `null` is an invalid supplied value, not an
192    /// omission: JSON has no way to spell JavaScript's `undefined`, so every
193    /// member of an object counts as present.
194    pub fn from_json(value: &Value) -> Result<Policy, PolicyError> {
195        let mut policy = Policy::default();
196        if value.is_null() {
197            return Ok(policy);
198        }
199        let config = policy_object(value, "defaultConfig")?;
200        reject_shadow_ramp(config)?;
201        if let Some(layers) = config.get("ttlSec") {
202            let layers = policy_object(layers, "ttlSec")?;
203            for (layer, slot) in [
204                ("local", &mut policy.local_ttl_sec),
205                ("remote", &mut policy.remote_ttl_sec),
206            ] {
207                if let Some(leaf) = layers.get(layer) {
208                    let seconds = ttl_sec(leaf)
209                        .ok_or_else(|| PolicyError(format!("invalid static ttlSec.{layer}")))?;
210                    *slot = Some(seconds);
211                }
212            }
213        }
214        if let Some(layers) = config.get("ramp") {
215            let layers = policy_object(layers, "ramp")?;
216            for (layer, slot) in [
217                ("local", &mut policy.local_ramp),
218                ("remote", &mut policy.remote_ramp),
219            ] {
220                if let Some(leaf) = layers.get(layer) {
221                    let ramp = finite_range(leaf, 0.0, 100.0, false)
222                        .ok_or_else(|| PolicyError(format!("invalid static ramp.{layer}")))?;
223                    *slot = Some(ramp);
224                }
225            }
226        }
227        for (field, slot) in [
228            ("requestLocal", &mut policy.request_local),
229            ("coalesce", &mut policy.coalesce),
230        ] {
231            if let Some(leaf) = config.get(field) {
232                let flag = leaf
233                    .as_bool()
234                    .ok_or_else(|| PolicyError(format!("{field} must be boolean")))?;
235                *slot = Some(flag);
236            }
237        }
238        if let Some(leaf) = config.get("staleOnErrorMaxAgeSec") {
239            let seconds = finite_range(leaf, 0.0, MAX_CACHE_TTL_SEC as f64, true)
240                .ok_or_else(|| PolicyError("invalid static staleOnErrorMaxAgeSec".to_string()))?;
241            policy.stale_on_error_max_age_sec = Some(seconds as u64);
242        }
243        if let Some(leaf) = config.get("remoteReadTimeoutMs") {
244            let ms = finite_range(leaf, 1.0, MAX_DEADLINE_MS as f64, true)
245                .ok_or_else(|| PolicyError("invalid remoteReadTimeoutMs".to_string()))?;
246            policy.remote_read_timeout_ms = Some(ms as u64);
247        }
248        if let Some(shadow) = config.get("shadow") {
249            let shadow = policy_object(shadow, "shadow")?;
250            let mut parsed = ShadowPolicy::default();
251            if let Some(leaf) = shadow.get("ramp") {
252                let ramp = finite_range(leaf, 0.0, 100.0, false)
253                    .ok_or_else(|| PolicyError("invalid static shadow.ramp".to_string()))?;
254                parsed.ramp = Some(ramp);
255            }
256            if let Some(leaf) = shadow.get("logMismatches") {
257                let flag = leaf.as_bool().ok_or_else(|| {
258                    PolicyError("shadow.logMismatches must be boolean".to_string())
259                })?;
260                parsed.log_mismatches = Some(flag);
261            }
262            if let Some(group) = shadow.get("mismatchLogging") {
263                let group = policy_object(group, "shadow mismatchLogging config")?;
264                let mut leaves = MismatchLogging::default();
265                if let Some(leaf) = group.get("value") {
266                    let flag = leaf.as_bool().ok_or_else(|| {
267                        PolicyError("shadow.mismatchLogging.value must be boolean".to_string())
268                    })?;
269                    leaves.value = Some(flag);
270                }
271                if let Some(leaf) = group.get("diff") {
272                    let level = MismatchDiff::from_json(leaf).ok_or_else(|| {
273                        PolicyError("invalid static shadow.mismatchLogging.diff".to_string())
274                    })?;
275                    leaves.diff = Some(level);
276                }
277                parsed.mismatch_logging = Some(leaves);
278            }
279            policy.shadow = Some(parsed);
280        }
281        policy.validate()?;
282        Ok(policy)
283    }
284
285    /// The JSON-shaped form of this policy, with omitted leaves absent.
286    ///
287    /// `ttlSec` and `ramp` are always objects (possibly empty). All other
288    /// leaves, including `requestLocal` and `coalesce`, appear only when set,
289    /// so converting this policy to a runtime overlay preserves inheritance.
290    pub fn to_json(&self) -> Value {
291        let mut ttl = Map::new();
292        if let Some(seconds) = self.local_ttl_sec {
293            ttl.insert("local".to_string(), Value::from(seconds));
294        }
295        if let Some(seconds) = self.remote_ttl_sec {
296            ttl.insert("remote".to_string(), Value::from(seconds));
297        }
298        let mut ramp = Map::new();
299        if let Some(value) = self.local_ramp {
300            ramp.insert("local".to_string(), ramp_json(value));
301        }
302        if let Some(value) = self.remote_ramp {
303            ramp.insert("remote".to_string(), ramp_json(value));
304        }
305        let mut object = Map::new();
306        object.insert("ttlSec".to_string(), Value::Object(ttl));
307        object.insert("ramp".to_string(), Value::Object(ramp));
308        if let Some(enabled) = self.request_local {
309            object.insert("requestLocal".to_string(), Value::Bool(enabled));
310        }
311        if let Some(enabled) = self.coalesce {
312            object.insert("coalesce".to_string(), Value::Bool(enabled));
313        }
314        if let Some(seconds) = self.stale_on_error_max_age_sec {
315            object.insert("staleOnErrorMaxAgeSec".to_string(), Value::from(seconds));
316        }
317        if let Some(ms) = self.remote_read_timeout_ms {
318            object.insert("remoteReadTimeoutMs".to_string(), Value::from(ms));
319        }
320        if let Some(shadow) = &self.shadow {
321            let mut leaves = Map::new();
322            if let Some(value) = shadow.ramp {
323                leaves.insert("ramp".to_string(), ramp_json(value));
324            }
325            if let Some(flag) = shadow.log_mismatches {
326                leaves.insert("logMismatches".to_string(), Value::Bool(flag));
327            }
328            if let Some(group) = shadow.mismatch_logging {
329                let mut written = Map::new();
330                if let Some(flag) = group.value {
331                    written.insert("value".to_string(), Value::Bool(flag));
332                }
333                if let Some(level) = group.diff {
334                    written.insert("diff".to_string(), Value::from(level.as_str()));
335                }
336                leaves.insert("mismatchLogging".to_string(), Value::Object(written));
337            }
338            object.insert("shadow".to_string(), Value::Object(leaves));
339        }
340        Value::Object(object)
341    }
342
343    /// Reject statically invalid leaves.
344    pub fn validate(&self) -> Result<(), PolicyError> {
345        for ttl in [self.local_ttl_sec, self.remote_ttl_sec]
346            .into_iter()
347            .flatten()
348        {
349            if !(1..=MAX_CACHE_TTL_SEC).contains(&ttl) {
350                return Err(PolicyError(
351                    "static TTL must be whole seconds within 365 days".to_string(),
352                ));
353            }
354        }
355        for ramp in [self.local_ramp, self.remote_ramp].into_iter().flatten() {
356            if !ramp_in_domain(ramp) {
357                return Err(PolicyError(
358                    "static ramp must be between zero and 100".to_string(),
359                ));
360            }
361        }
362        if let Some(age) = self.stale_on_error_max_age_sec {
363            let exceeds_remote = match self.remote_ttl_sec {
364                Some(remote) if remote > 0 => age > remote,
365                _ => false,
366            };
367            if age > MAX_CACHE_TTL_SEC || (age > 0 && !exceeds_remote) {
368                return Err(PolicyError(
369                    "static recovery age must exceed a positive remote TTL".to_string(),
370                ));
371            }
372        }
373        if let Some(ms) = self.remote_read_timeout_ms {
374            if !(1..=MAX_DEADLINE_MS).contains(&ms) {
375                return Err(PolicyError("invalid remote read deadline".to_string()));
376            }
377        }
378        if let Some(ramp) = self.shadow.as_ref().and_then(|shadow| shadow.ramp) {
379            if !ramp_in_domain(ramp) {
380                return Err(PolicyError(
381                    "static shadow ramp must be between zero and 100".to_string(),
382                ));
383            }
384        }
385        Ok(())
386    }
387}
388
389/// A sparse runtime overlay returned by a [`PolicyProvider`](crate::PolicyProvider).
390///
391/// Each present leaf replaces the operation leaf; omitted leaves inherit.
392/// Leaves may hold invalid values on purpose: their consequences are defined
393/// by the portable contract (an invalid TTL or ramp disables only that
394/// layer, an invalid flag or read deadline bypasses caching for the call).
395#[derive(Debug, Clone, PartialEq)]
396pub struct RuntimePolicy(pub Value);
397
398impl RuntimePolicy {
399    /// Wrap a JSON overlay as received from a configuration source. Nothing
400    /// is validated here; resolution judges every leaf per call.
401    pub fn from_json(value: Value) -> Self {
402        RuntimePolicy(value)
403    }
404}
405
406impl From<Policy> for RuntimePolicy {
407    fn from(policy: Policy) -> Self {
408        RuntimePolicy(policy.to_json())
409    }
410}
411
412impl From<Value> for RuntimePolicy {
413    fn from(value: Value) -> Self {
414        RuntimePolicy(value)
415    }
416}
417
418/// Instance defaults consulted during resolution.
419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub struct PolicyDefaults {
421    /// Instance remote read budget in milliseconds, used when neither the
422    /// operation nor the overlay sets one. Zero selects the library default
423    /// of 50 ms.
424    pub remote_read_timeout_ms: u64,
425}
426
427impl Default for PolicyDefaults {
428    fn default() -> Self {
429        PolicyDefaults {
430            remote_read_timeout_ms: DEFAULT_REMOTE_READ_TIMEOUT_MS,
431        }
432    }
433}
434
435/// Why a whole invocation's policy could not be resolved.
436#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
437#[error("{0}")]
438pub struct PolicyError(pub String);
439
440/// One serving layer after resolution.
441#[derive(Debug, Clone, Copy, PartialEq)]
442pub struct ResolvedLayer {
443    /// Whether the layer serves this key: configured and admitted by its ramp.
444    pub enabled: bool,
445    /// Why the layer is disabled, when it is.
446    pub reason: Option<DisabledReason>,
447    /// A valid TTL and ramp remain available when the ramp excluded this key.
448    pub configured: bool,
449    /// The effective TTL in milliseconds; zero when not configured.
450    pub ttl_ms: u64,
451    /// The effective cohort percentage; zero when not configured.
452    pub ramp: f64,
453}
454
455impl ResolvedLayer {
456    fn disabled(reason: DisabledReason) -> Self {
457        ResolvedLayer {
458            enabled: false,
459            reason: Some(reason),
460            configured: false,
461            ttl_ms: 0,
462            ramp: 0.0,
463        }
464    }
465}
466
467/// Shadow policy after resolution.
468#[derive(Debug, Clone, Copy, PartialEq, Default)]
469pub struct ResolvedShadow {
470    /// Cohort selection only; admission still needs an eligible path, hook and capacity.
471    pub enabled: bool,
472    /// The shadow cohort percentage; zero when omitted or invalid.
473    pub ramp: f64,
474    /// Whether a confirmed-mismatch warning carries the JSON of both values:
475    /// the effective `mismatchLogging.value` leaf after the shorthand
476    /// expanded, `false` when omitted or invalid.
477    pub mismatch_value: bool,
478    /// How much of the difference a confirmed-mismatch warning carries: the
479    /// effective `mismatchLogging.diff` leaf, [`MismatchDiff::Off`] when
480    /// omitted or invalid.
481    pub mismatch_diff: MismatchDiff,
482    /// The shadow ramp leaf was present but invalid: shadow work is off and a
483    /// `config_resolution` error is emitted on the remote layer whenever a
484    /// shadow job would otherwise have been scheduled (a remote hit or a
485    /// ramped-down remote).
486    pub config_error: bool,
487    /// A mismatch-logging leaf or the `logMismatches` shorthand was invalid
488    /// and acts off. One `config_resolution` covers all of them, recorded only
489    /// if a job is admitted.
490    pub logging_config_error: bool,
491}
492
493impl ResolvedShadow {
494    /// Whether a confirmed mismatch emits a warning: `value` is on or `diff`
495    /// is above [`MismatchDiff::Off`].
496    pub fn logs_mismatches(&self) -> bool {
497        self.mismatch_value || self.mismatch_diff != MismatchDiff::Off
498    }
499}
500
501/// The captured policy of one enabled invocation.
502#[derive(Debug, Clone, PartialEq)]
503pub struct ResolvedPolicy {
504    /// Whether the call memoizes in the outermost enabled scope.
505    pub request_local: bool,
506    /// Whether the call joins or leads a single flight for its key.
507    pub coalesce: bool,
508    /// The process-local layer.
509    pub local: ResolvedLayer,
510    /// The remote layer.
511    pub remote: ResolvedLayer,
512    /// The effective remote read budget in milliseconds: overlay, then
513    /// operation, then instance default.
514    pub remote_read_timeout_ms: u64,
515    /// Zero when recovery is off.
516    pub stale_on_error_max_age_ms: u64,
517    /// The recovery age leaf was present but invalid (not whole seconds, or
518    /// not above the remote TTL): recovery is off and a `config_resolution`
519    /// error is emitted on the remote layer.
520    pub stale_on_error_config_error: bool,
521    /// The shadow validation policy.
522    pub shadow: ResolvedShadow,
523}
524
525/// Merge a static policy with a sparse overlay once, for one logical key.
526///
527/// A malformed container, boolean or read deadline fails the whole
528/// invocation; TTL or ramp errors disable only that layer; an invalid
529/// recovery or shadow option preserves ordinary serving.
530pub fn resolve_policy(
531    base: &Policy,
532    overlay: Option<&RuntimePolicy>,
533    logical_key: &str,
534    defaults: PolicyDefaults,
535) -> Result<ResolvedPolicy, PolicyError> {
536    let mut remote_read_timeout_ms = defaults.remote_read_timeout_ms;
537    if remote_read_timeout_ms == 0 {
538        remote_read_timeout_ms = DEFAULT_REMOTE_READ_TIMEOUT_MS;
539    }
540    if remote_read_timeout_ms > MAX_DEADLINE_MS {
541        return Err(PolicyError("invalid instance read deadline".to_string()));
542    }
543    base.validate()?;
544    let mut merged = match base.to_json() {
545        Value::Object(object) => object,
546        _ => unreachable!("Policy::to_json always produces an object"),
547    };
548    // Defaults belong to resolution, not the sparse policy representation.
549    // Present overlay values (including invalid nulls) still replace them.
550    merged.entry("requestLocal").or_insert(Value::Bool(false));
551    merged.entry("coalesce").or_insert(Value::Bool(true));
552    if let Some(RuntimePolicy(overlay)) = overlay {
553        if !overlay.is_null() {
554            merge_overlay(&mut merged, overlay)?;
555        }
556    }
557
558    let request_local = merged
559        .get("requestLocal")
560        .and_then(Value::as_bool)
561        .ok_or_else(|| PolicyError("runtime requestLocal must be boolean".to_string()))?;
562    let coalesce = merged
563        .get("coalesce")
564        .and_then(Value::as_bool)
565        .ok_or_else(|| PolicyError("runtime coalesce must be boolean".to_string()))?;
566    if let Some(leaf) = merged.get("remoteReadTimeoutMs") {
567        let ms = finite_range(leaf, 1.0, MAX_DEADLINE_MS as f64, true)
568            .ok_or_else(|| PolicyError("invalid runtime remoteReadTimeoutMs".to_string()))?;
569        remote_read_timeout_ms = ms as u64;
570    }
571
572    let empty = Map::new();
573    let ttls = merged
574        .get("ttlSec")
575        .and_then(Value::as_object)
576        .unwrap_or(&empty);
577    let ramps = merged
578        .get("ramp")
579        .and_then(Value::as_object)
580        .unwrap_or(&empty);
581    let local = resolve_layer(ttls, ramps, logical_key, "local");
582    let remote = resolve_layer(ttls, ramps, logical_key, "remote");
583
584    let mut stale_on_error_max_age_ms = 0;
585    let mut stale_on_error_config_error = false;
586    if let Some(age) = merged.get("staleOnErrorMaxAgeSec") {
587        let numeric_zero = policy_number(age) == Some(0.0);
588        if !remote.configured {
589            stale_on_error_config_error =
590                remote.reason == Some(DisabledReason::PolicyDisabled) && !numeric_zero;
591        } else if !numeric_zero {
592            match ttl_sec(age).map(|seconds| seconds * 1_000) {
593                Some(ms) if ms > remote.ttl_ms => stale_on_error_max_age_ms = ms,
594                _ => stale_on_error_config_error = true,
595            }
596        }
597    }
598
599    let mut shadow = ResolvedShadow::default();
600    if let Some(leaves) = merged.get("shadow").and_then(Value::as_object) {
601        if let Some(leaf) = leaves.get("ramp") {
602            match finite_range(leaf, 0.0, 100.0, false) {
603                Some(ramp) => {
604                    shadow.ramp = ramp;
605                    shadow.enabled = admits(ramp, logical_key, "shadow");
606                }
607                None => shadow.config_error = true,
608            }
609        }
610        let logging = resolve_mismatch_logging(leaves);
611        shadow.mismatch_value = logging.value;
612        shadow.mismatch_diff = logging.diff;
613        shadow.logging_config_error = logging.invalid;
614    }
615
616    Ok(ResolvedPolicy {
617        request_local,
618        coalesce,
619        local,
620        remote,
621        remote_read_timeout_ms,
622        stale_on_error_max_age_ms,
623        stale_on_error_config_error,
624        shadow,
625    })
626}
627
628/// Apply a sparse runtime overlay to the static policy's JSON form. Every
629/// present member counts, including `null`, so an explicit null leaf replaces
630/// the operation leaf and is then judged as an invalid supplied value.
631fn merge_overlay(merged: &mut Map<String, Value>, overlay: &Value) -> Result<(), PolicyError> {
632    let overlay = policy_object(overlay, "runtime config")?;
633    reject_shadow_ramp(overlay)?;
634    for (name, leaves) in [
635        ("ttlSec", &["local", "remote"][..]),
636        ("ramp", &["local", "remote"][..]),
637    ] {
638        let Some(incoming) = overlay.get(name) else {
639            continue;
640        };
641        let incoming = policy_object(incoming, name)?;
642        let mut output = match merged.get(name) {
643            Some(Value::Object(existing)) => existing.clone(),
644            _ => Map::new(),
645        };
646        for leaf in leaves {
647            if let Some(value) = incoming.get(*leaf) {
648                output.insert((*leaf).to_string(), value.clone());
649            }
650        }
651        merged.insert(name.to_string(), Value::Object(output));
652    }
653    if let Some(incoming) = overlay.get("shadow") {
654        let incoming = policy_object(incoming, "shadow")?;
655        let inherited = match merged.get("shadow") {
656            Some(Value::Object(existing)) => existing.clone(),
657            _ => Map::new(),
658        };
659        merged.insert(
660            "shadow".to_string(),
661            Value::Object(merge_shadow(&inherited, incoming)?),
662        );
663    }
664    for name in [
665        "requestLocal",
666        "coalesce",
667        "staleOnErrorMaxAgeSec",
668        "remoteReadTimeoutMs",
669    ] {
670        if let Some(value) = overlay.get(name) {
671            merged.insert(name.to_string(), value.clone());
672        }
673    }
674    Ok(())
675}
676
677/// Merge one runtime `shadow` group over the inherited one. `ramp` merges as
678/// a leaf. Each layer's `logMismatches` shorthand expands to the leaves it
679/// stands for before the leaf-wise merge, so a runtime shorthand replaces both
680/// inherited leaves while a leaf written beside it still wins within its own
681/// layer. A non-object runtime `mismatchLogging` fails the merge.
682fn merge_shadow(
683    inherited: &Map<String, Value>,
684    incoming: &Map<String, Value>,
685) -> Result<Map<String, Value>, PolicyError> {
686    let mut output = Map::new();
687    if let Some(ramp) = incoming.get("ramp").or_else(|| inherited.get("ramp")) {
688        output.insert("ramp".to_string(), ramp.clone());
689    }
690    if let Some(group) = incoming.get("mismatchLogging") {
691        policy_object(group, "shadow mismatchLogging config")?;
692    }
693    let base = expand_mismatch_logging(inherited);
694    let written = expand_mismatch_logging(incoming);
695    // Only the runtime layer can carry a malformed shorthand: a static one is
696    // a typed boolean. It stays raw, so resolution records the one
697    // `config_resolution` it owes; its expanded leaves already won the merge.
698    if written.invalid {
699        if let Some(shorthand) = incoming.get("logMismatches") {
700            output.insert("logMismatches".to_string(), shorthand.clone());
701        }
702    }
703    let mut group = Map::new();
704    if let Some(value) = written.value.or(base.value) {
705        group.insert("value".to_string(), value);
706    }
707    if let Some(diff) = written.diff.or(base.diff) {
708        group.insert("diff".to_string(), diff);
709    }
710    if !group.is_empty() {
711        output.insert("mismatchLogging".to_string(), Value::Object(group));
712    }
713    Ok(output)
714}
715
716/// The mismatch-logging leaves one shadow group writes, before validation;
717/// `None` is unwritten. `invalid` marks a malformed shorthand.
718#[derive(Default)]
719struct MismatchLeaves {
720    value: Option<Value>,
721    diff: Option<Value>,
722    invalid: bool,
723}
724
725/// Expand one shadow group's `logMismatches` shorthand into the leaves it
726/// stands for, `true` as value on with diff `values` and `false` as both off,
727/// then let the leaves written in the same group's `mismatchLogging` override
728/// them. A malformed shorthand expands to both leaves off and is marked
729/// invalid.
730fn expand_mismatch_logging(shadow: &Map<String, Value>) -> MismatchLeaves {
731    let expanded = |value: bool, diff: MismatchDiff, invalid: bool| MismatchLeaves {
732        value: Some(Value::Bool(value)),
733        diff: Some(Value::from(diff.as_str())),
734        invalid,
735    };
736    let mut leaves = match shadow.get("logMismatches") {
737        None => MismatchLeaves::default(),
738        Some(Value::Bool(true)) => expanded(true, MismatchDiff::Values, false),
739        Some(Value::Bool(false)) => expanded(false, MismatchDiff::Off, false),
740        Some(_) => expanded(false, MismatchDiff::Off, true),
741    };
742    if let Some(group) = shadow.get("mismatchLogging").and_then(Value::as_object) {
743        if let Some(value) = group.get("value") {
744            leaves.value = Some(value.clone());
745        }
746        if let Some(diff) = group.get("diff") {
747            leaves.diff = Some(diff.clone());
748        }
749    }
750    leaves
751}
752
753/// The effective mismatch-logging leaves of one merged shadow group.
754struct MismatchLoggingResolution {
755    value: bool,
756    diff: MismatchDiff,
757    invalid: bool,
758}
759
760/// Resolve each leaf independently: an omitted leaf is off, an invalid leaf
761/// acts off, and `invalid` reports that the group owes one
762/// `config_resolution`, however many of its inputs were invalid.
763fn resolve_mismatch_logging(shadow: &Map<String, Value>) -> MismatchLoggingResolution {
764    let leaves = expand_mismatch_logging(shadow);
765    let value = match &leaves.value {
766        None => Some(false),
767        Some(leaf) => leaf.as_bool(),
768    };
769    let diff = match &leaves.diff {
770        None => Some(MismatchDiff::Off),
771        Some(leaf) => MismatchDiff::from_json(leaf),
772    };
773    MismatchLoggingResolution {
774        value: value.unwrap_or(false),
775        diff: diff.unwrap_or(MismatchDiff::Off),
776        invalid: leaves.invalid || value.is_none() || diff.is_none(),
777    }
778}
779
780fn resolve_layer(
781    ttls: &Map<String, Value>,
782    ramps: &Map<String, Value>,
783    logical_key: &str,
784    layer: &str,
785) -> ResolvedLayer {
786    let Some(ttl) = ttls.get(layer) else {
787        return ResolvedLayer::disabled(DisabledReason::PolicyDisabled);
788    };
789    let Some(ttl_ms) = ttl_sec(ttl).map(|seconds| seconds * 1_000) else {
790        return ResolvedLayer::disabled(DisabledReason::InvalidTtl);
791    };
792    let ramp = match ramps.get(layer) {
793        None => 100.0,
794        Some(leaf) => match finite_range(leaf, 0.0, 100.0, false) {
795            Some(ramp) => ramp,
796            None => return ResolvedLayer::disabled(DisabledReason::InvalidRamp),
797        },
798    };
799    let enabled = admits(ramp, logical_key, layer);
800    ResolvedLayer {
801        enabled,
802        reason: if enabled {
803            None
804        } else {
805            Some(DisabledReason::RampedDown)
806        },
807        configured: true,
808        ttl_ms,
809        ramp,
810    }
811}
812
813/// Cohort admission: a full ramp admits every key, a zero ramp none, and a
814/// partial ramp admits keys whose stable sample is strictly below it.
815fn admits(ramp: f64, logical_key: &str, discriminator: &str) -> bool {
816    ramp >= 100.0 || (ramp > 0.0 && crate::identity::cohort(logical_key, discriminator) < ramp)
817}
818
819fn ramp_in_domain(ramp: f64) -> bool {
820    ramp.is_finite() && (0.0..=100.0).contains(&ramp)
821}
822
823/// Spell a ramp the way JavaScript and Go's `encoding/json` do: an integral
824/// value has no fraction (`100`, not `100.0`), so a static policy's JSON form
825/// compares equal to the same policy parsed from text.
826fn ramp_json(ramp: f64) -> Value {
827    if ramp_in_domain(ramp) && ramp.trunc() == ramp {
828        Value::from(ramp as u64)
829    } else {
830        Value::from(ramp)
831    }
832}
833
834/// The numeric domain: JSON numbers only. Strings and booleans never count.
835fn policy_number(value: &Value) -> Option<f64> {
836    value.as_number().and_then(serde_json::Number::as_f64)
837}
838
839fn finite_range(value: &Value, min: f64, max: f64, integer: bool) -> Option<f64> {
840    let n = policy_number(value)?;
841    (n.is_finite() && n >= min && n <= max && (!integer || n.trunc() == n)).then_some(n)
842}
843
844/// A TTL-domain leaf in whole seconds.
845fn ttl_sec(value: &Value) -> Option<u64> {
846    finite_range(value, 1.0, MAX_CACHE_TTL_SEC as f64, true).map(|n| n as u64)
847}
848
849fn policy_object<'a>(value: &'a Value, name: &str) -> Result<&'a Map<String, Value>, PolicyError> {
850    value
851        .as_object()
852        .ok_or_else(|| PolicyError(format!("DialCache {name} must be an object")))
853}
854
855fn reject_shadow_ramp(object: &Map<String, Value>) -> Result<(), PolicyError> {
856    if object.contains_key("shadowRamp") {
857        return Err(PolicyError(
858            "shadowRamp was replaced by shadow.ramp".to_string(),
859        ));
860    }
861    Ok(())
862}
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867    use serde_json::json;
868
869    const KEY: &str = "policy:item:one#lookup";
870    const MAX_TTL: u64 = MAX_CACHE_TTL_SEC;
871
872    fn resolve(base: &Policy, overlay: Option<Value>) -> Result<ResolvedPolicy, PolicyError> {
873        let overlay = overlay.map(RuntimePolicy);
874        resolve_policy(base, overlay.as_ref(), KEY, PolicyDefaults::default())
875    }
876
877    fn resolved(base: &Policy, overlay: Option<Value>) -> ResolvedPolicy {
878        resolve(base, overlay).expect("policy resolves")
879    }
880
881    // Port of TestPolicySparseResolutionAndSnapshots.
882    #[test]
883    fn sparse_resolution_and_inheritance() {
884        let base = Policy::from_json(&json!({
885            "requestLocal": true, "coalesce": false,
886            "ttlSec": {"local": 1.0, "remote": 2.0},
887            "staleOnErrorMaxAgeSec": 5.0, "remoteReadTimeoutMs": 30.0,
888            "shadow": {"ramp": 100.0, "logMismatches": true},
889        }))
890        .unwrap();
891        let overlay = RuntimePolicy(json!({
892            "ttlSec": {"remote": 4.0},
893            "ramp": {"local": 0.0},
894            "shadow": {"logMismatches": false},
895        }));
896        let r = resolve_policy(
897            &base,
898            Some(&overlay),
899            KEY,
900            PolicyDefaults {
901                remote_read_timeout_ms: 20,
902            },
903        )
904        .unwrap();
905        assert!(r.request_local);
906        assert!(!r.coalesce);
907        assert!(!r.local.enabled);
908        assert_eq!(r.local.reason, Some(DisabledReason::RampedDown));
909        assert!(r.local.configured);
910        assert_eq!(r.local.ttl_ms, 1_000);
911        assert_eq!(r.local.ramp, 0.0);
912        assert!(r.remote.enabled);
913        assert_eq!(r.remote.reason, None);
914        assert_eq!(r.remote.ttl_ms, 4_000);
915        assert_eq!(r.remote.ramp, 100.0);
916        assert_eq!(r.stale_on_error_max_age_ms, 5_000);
917        assert!(!r.stale_on_error_config_error);
918        assert_eq!(r.remote_read_timeout_ms, 30);
919        assert!(r.shadow.enabled);
920        assert_eq!(r.shadow.ramp, 100.0);
921        assert!(!r.shadow.logs_mismatches());
922        assert!(!r.shadow.config_error);
923        assert!(!r.shadow.logging_config_error);
924
925        // A clone is an independent snapshot of every leaf.
926        let snapshot = base.clone();
927        let mut mutated = base.clone();
928        mutated.remote_read_timeout_ms = Some(99);
929        mutated.shadow.as_mut().unwrap().ramp = Some(0.0);
930        assert_eq!(snapshot.remote_read_timeout_ms, Some(30));
931        assert_eq!(snapshot.shadow.as_ref().unwrap().ramp, Some(100.0));
932
933        // A null provider reply inherits the whole operation policy.
934        for overlay in [None, Some(Value::Null)] {
935            let inherit = resolve_policy(
936                &snapshot,
937                overlay.map(RuntimePolicy).as_ref(),
938                KEY,
939                PolicyDefaults::default(),
940            )
941            .unwrap();
942            assert!(inherit.local.enabled);
943            assert!(inherit.remote.enabled);
944            assert_eq!(inherit.remote_read_timeout_ms, 30);
945            assert!(inherit.shadow.enabled);
946            assert!(inherit.shadow.mismatch_value);
947            assert_eq!(inherit.shadow.mismatch_diff, MismatchDiff::Values);
948        }
949    }
950
951    // Port of TestPolicyStaticValidation.
952    #[test]
953    fn static_validation_rejects_malformed_configs() {
954        let invalid = [
955            json!(true),
956            json!([]),
957            json!("policy"),
958            json!(1),
959            json!({"shadowRamp": 1}),
960            json!({"ttlSec": null}),
961            json!({"ramp": null}),
962            json!({"shadow": null}),
963            json!({"ttlSec": []}),
964            json!({"ttlSec": 60}),
965            json!({"shadow": true}),
966            json!({"requestLocal": null}),
967            json!({"coalesce": null}),
968            json!({"coalesce": "false"}),
969            json!({"requestLocal": 1}),
970            json!({"ttlSec": {"local": null}}),
971            json!({"ttlSec": {"local": 0}}),
972            json!({"ttlSec": {"local": -1}}),
973            json!({"ttlSec": {"local": 1.5}}),
974            json!({"ttlSec": {"local": "60"}}),
975            json!({"ttlSec": {"remote": 31_536_001}}),
976            json!({"ramp": {"remote": null}}),
977            json!({"ramp": {"remote": "nan"}}),
978            json!({"ramp": {"remote": -0.5}}),
979            json!({"ramp": {"local": 100.5}}),
980            json!({"ramp": {"local": true}}),
981            json!({"remoteReadTimeoutMs": null}),
982            json!({"remoteReadTimeoutMs": 0}),
983            json!({"remoteReadTimeoutMs": 1.5}),
984            json!({"remoteReadTimeoutMs": 2_147_483_648u64}),
985            json!({"staleOnErrorMaxAgeSec": null}),
986            json!({"staleOnErrorMaxAgeSec": -1}),
987            json!({"staleOnErrorMaxAgeSec": 2.5}),
988            json!({"staleOnErrorMaxAgeSec": 2}),
989            json!({"ttlSec": {"remote": 2}, "staleOnErrorMaxAgeSec": 2}),
990            json!({"ttlSec": {"remote": 2}, "staleOnErrorMaxAgeSec": 1}),
991            json!({"ttlSec": {"local": 2}, "staleOnErrorMaxAgeSec": 3}),
992            json!({"ttlSec": {"remote": 2}, "staleOnErrorMaxAgeSec": 31_536_001}),
993            json!({"shadow": {"ramp": null}}),
994            json!({"shadow": {"ramp": 101}}),
995            json!({"shadow": {"ramp": "50"}}),
996            json!({"shadow": {"logMismatches": null}}),
997            json!({"shadow": {"logMismatches": 1}}),
998            json!({"shadow": {"mismatchLogging": null}}),
999            json!({"shadow": {"mismatchLogging": []}}),
1000            json!({"shadow": {"mismatchLogging": true}}),
1001            json!({"shadow": {"mismatchLogging": {"value": null}}}),
1002            json!({"shadow": {"mismatchLogging": {"value": "true"}}}),
1003            json!({"shadow": {"mismatchLogging": {"diff": null}}}),
1004            json!({"shadow": {"mismatchLogging": {"diff": "full"}}}),
1005            json!({"shadow": {"mismatchLogging": {"diff": true}}}),
1006            json!({"shadow": {"mismatchLogging": {"diff": "Paths"}}}),
1007        ];
1008        for config in invalid {
1009            assert!(
1010                Policy::from_json(&config).is_err(),
1011                "accepted invalid static config: {config}"
1012            );
1013        }
1014
1015        let accepted = Policy::from_json(&json!({
1016            "ttlSec": {"remote": 31_536_000},
1017            "remoteReadTimeoutMs": 2_147_483_647u64,
1018        }))
1019        .unwrap();
1020        assert_eq!(accepted.remote_ttl_sec, Some(MAX_TTL));
1021        assert_eq!(accepted.remote_read_timeout_ms, Some(MAX_DEADLINE_MS));
1022        assert_eq!(accepted.local_ttl_sec, None);
1023
1024        // Zero recovery age is valid without a remote TTL; a positive one needs to exceed it.
1025        assert!(Policy::from_json(&json!({"staleOnErrorMaxAgeSec": 0})).is_ok());
1026        assert!(
1027            Policy::from_json(&json!({"ttlSec": {"remote": 2}, "staleOnErrorMaxAgeSec": 3}))
1028                .is_ok()
1029        );
1030        // Integer-valued floats and unsigned integers share one numeric domain.
1031        let floats =
1032            Policy::from_json(&json!({"ttlSec": {"local": 60.0}, "ramp": {"local": 50}})).unwrap();
1033        assert_eq!(floats.local_ttl_sec, Some(60));
1034        assert_eq!(floats.local_ramp, Some(50.0));
1035        // Ramp boundaries are inclusive.
1036        assert!(Policy::from_json(
1037            &json!({"ramp": {"local": 0, "remote": 100}, "shadow": {"ramp": 0}})
1038        )
1039        .is_ok());
1040
1041        // Direct validation mirrors the same domains without the parser.
1042        assert!(Policy::default().local_ttl_sec(0).validate().is_err());
1043        assert!(Policy::default()
1044            .local_ttl_sec(MAX_TTL + 1)
1045            .validate()
1046            .is_err());
1047        assert!(Policy::default().remote_ttl_sec(MAX_TTL).validate().is_ok());
1048        assert!(Policy::default().local_ramp(f64::NAN).validate().is_err());
1049        assert!(Policy::default()
1050            .remote_ramp(f64::INFINITY)
1051            .validate()
1052            .is_err());
1053        assert!(Policy::default().remote_ramp(100.0001).validate().is_err());
1054        assert!(Policy::default().local_ramp(-0.1).validate().is_err());
1055        assert!(Policy::default()
1056            .remote_read_timeout_ms(0)
1057            .validate()
1058            .is_err());
1059        assert!(Policy::default()
1060            .remote_read_timeout_ms(MAX_DEADLINE_MS + 1)
1061            .validate()
1062            .is_err());
1063        assert!(Policy::default()
1064            .remote_read_timeout_ms(MAX_DEADLINE_MS)
1065            .validate()
1066            .is_ok());
1067        assert!(Policy::default()
1068            .stale_on_error_max_age_sec(1)
1069            .validate()
1070            .is_err());
1071        assert!(Policy::default()
1072            .stale_on_error_max_age_sec(0)
1073            .validate()
1074            .is_ok());
1075        assert!(Policy::default()
1076            .remote_ttl_sec(2)
1077            .stale_on_error_max_age_sec(2)
1078            .validate()
1079            .is_err());
1080        assert!(Policy::default()
1081            .remote_ttl_sec(2)
1082            .stale_on_error_max_age_sec(3)
1083            .validate()
1084            .is_ok());
1085        assert!(Policy::default()
1086            .remote_ttl_sec(2)
1087            .stale_on_error_max_age_sec(MAX_TTL + 1)
1088            .validate()
1089            .is_err());
1090        let nan_shadow = ShadowPolicy {
1091            ramp: Some(f64::NAN),
1092            ..ShadowPolicy::default()
1093        };
1094        assert!(Policy::default().shadow(nan_shadow).validate().is_err());
1095        let high_shadow = ShadowPolicy {
1096            ramp: Some(100.5),
1097            ..ShadowPolicy::default()
1098        };
1099        assert!(Policy::default().shadow(high_shadow).validate().is_err());
1100        let flag_only = ShadowPolicy {
1101            log_mismatches: Some(true),
1102            mismatch_logging: Some(MismatchLogging {
1103                value: Some(false),
1104                diff: Some(MismatchDiff::Paths),
1105            }),
1106            ..ShadowPolicy::default()
1107        };
1108        assert!(Policy::default().shadow(flag_only).validate().is_ok());
1109        assert!(Policy::enabled(60).validate().is_ok());
1110        assert!(Policy::disabled().validate().is_ok());
1111    }
1112
1113    #[test]
1114    fn from_json_treats_absent_and_null_wholes_as_empty() {
1115        assert_eq!(Policy::from_json(&Value::Null).unwrap(), Policy::default());
1116        assert_eq!(Policy::from_json(&json!({})).unwrap(), Policy::default());
1117        // Unknown members are ignored, as in the TypeScript constructor.
1118        assert_eq!(
1119            Policy::from_json(&json!({"unknown": 1})).unwrap(),
1120            Policy::default()
1121        );
1122        // Empty containers are valid and leave every leaf omitted.
1123        let empty = Policy::from_json(&json!({"ttlSec": {}, "ramp": {}, "shadow": {}})).unwrap();
1124        assert_eq!(empty.local_ttl_sec, None);
1125        assert_eq!(empty.shadow, Some(ShadowPolicy::default()));
1126    }
1127
1128    #[test]
1129    fn from_json_round_trips_through_to_json() {
1130        let source = json!({
1131            "ttlSec": {"local": 5, "remote": 60},
1132            "ramp": {"local": 12.5, "remote": 100},
1133            "requestLocal": true,
1134            "coalesce": false,
1135            "staleOnErrorMaxAgeSec": 120,
1136            "remoteReadTimeoutMs": 25,
1137            "shadow": {
1138                "ramp": 3.5,
1139                "logMismatches": true,
1140                "mismatchLogging": {"value": false, "diff": "paths"},
1141            },
1142        });
1143        let policy = Policy::from_json(&source).unwrap();
1144        assert_eq!(
1145            policy,
1146            Policy {
1147                request_local: Some(true),
1148                coalesce: Some(false),
1149                local_ttl_sec: Some(5),
1150                remote_ttl_sec: Some(60),
1151                local_ramp: Some(12.5),
1152                remote_ramp: Some(100.0),
1153                stale_on_error_max_age_sec: Some(120),
1154                remote_read_timeout_ms: Some(25),
1155                shadow: Some(ShadowPolicy {
1156                    ramp: Some(3.5),
1157                    log_mismatches: Some(true),
1158                    mismatch_logging: Some(MismatchLogging {
1159                        value: Some(false),
1160                        diff: Some(MismatchDiff::Paths),
1161                    }),
1162                }),
1163            }
1164        );
1165        assert_eq!(policy.to_json(), source);
1166        assert_eq!(Policy::from_json(&policy.to_json()).unwrap(), policy);
1167    }
1168
1169    #[test]
1170    fn to_json_matches_the_static_policy_map_shape() {
1171        // Empty policy: layer maps present but empty, all other leaves absent.
1172        assert_eq!(
1173            Policy::default().to_json(),
1174            json!({"ttlSec": {}, "ramp": {}})
1175        );
1176        // Only present leaves appear inside the layer maps; integral ramps spell without a fraction.
1177        assert_eq!(
1178            Policy::default()
1179                .local_ttl_sec(7)
1180                .remote_ramp(40.0)
1181                .to_json(),
1182            json!({"ttlSec": {"local": 7}, "ramp": {"remote": 40}})
1183        );
1184        assert_eq!(
1185            Policy::default().local_ramp(12.5).to_json(),
1186            json!({"ttlSec": {}, "ramp": {"local": 12.5}})
1187        );
1188        // An explicit flag value is emitted even when it equals the default.
1189        assert_eq!(
1190            Policy::default()
1191                .request_local(false)
1192                .coalesce(true)
1193                .to_json(),
1194            json!({"ttlSec": {}, "ramp": {}, "requestLocal": false, "coalesce": true})
1195        );
1196        // A present but empty shadow policy is an empty object.
1197        assert_eq!(
1198            Policy::default().shadow(ShadowPolicy::default()).to_json(),
1199            json!({"ttlSec": {}, "ramp": {}, "shadow": {}})
1200        );
1201        assert_eq!(
1202            Policy::default()
1203                .shadow(ShadowPolicy {
1204                    log_mismatches: Some(false),
1205                    ..ShadowPolicy::default()
1206                })
1207                .to_json(),
1208            json!({
1209                "ttlSec": {}, "ramp": {},
1210                "shadow": {"logMismatches": false},
1211            })
1212        );
1213        // Only the set mismatch-logging leaves appear in their group.
1214        assert_eq!(
1215            Policy::default()
1216                .shadow(ShadowPolicy {
1217                    mismatch_logging: Some(MismatchLogging {
1218                        value: None,
1219                        diff: Some(MismatchDiff::Values),
1220                    }),
1221                    ..ShadowPolicy::default()
1222                })
1223                .to_json(),
1224            json!({
1225                "ttlSec": {}, "ramp": {},
1226                "shadow": {"mismatchLogging": {"diff": "values"}},
1227            })
1228        );
1229        // Zero recovery age is a present leaf, not an omission.
1230        assert_eq!(
1231            Policy::default()
1232                .stale_on_error_max_age_sec(0)
1233                .remote_read_timeout_ms(75)
1234                .to_json(),
1235            json!({
1236                "ttlSec": {}, "ramp": {},
1237                "staleOnErrorMaxAgeSec": 0, "remoteReadTimeoutMs": 75,
1238            })
1239        );
1240        assert_eq!(
1241            Policy::enabled(60).to_json(),
1242            json!({
1243                "ttlSec": {"local": 60, "remote": 60},
1244                "ramp": {"local": 100, "remote": 100},
1245            })
1246        );
1247        assert_eq!(
1248            Policy::disabled().to_json(),
1249            json!({
1250                "ttlSec": {},
1251                "ramp": {"local": 0, "remote": 0},
1252                "requestLocal": false,
1253                "staleOnErrorMaxAgeSec": 0,
1254                "shadow": {"ramp": 0, "mismatchLogging": {"value": false, "diff": "off"}},
1255            })
1256        );
1257    }
1258
1259    #[test]
1260    fn enabled_and_disabled_presets_resolve_as_documented() {
1261        let enabled = resolved(&Policy::enabled(60), None);
1262        assert!(!enabled.request_local);
1263        assert!(enabled.coalesce);
1264        assert!(enabled.local.enabled && enabled.remote.enabled);
1265        assert_eq!(
1266            (enabled.local.ttl_ms, enabled.remote.ttl_ms),
1267            (60_000, 60_000)
1268        );
1269        assert_eq!((enabled.local.ramp, enabled.remote.ramp), (100.0, 100.0));
1270        assert_eq!(
1271            enabled.remote_read_timeout_ms,
1272            DEFAULT_REMOTE_READ_TIMEOUT_MS
1273        );
1274        assert_eq!(enabled.stale_on_error_max_age_ms, 0);
1275        assert!(!enabled.stale_on_error_config_error);
1276        assert_eq!(enabled.shadow, ResolvedShadow::default());
1277
1278        // Standing alone, the kill switch has no TTLs: both layers are simply not configured,
1279        // and its zero recovery age is not a configuration error.
1280        let alone = resolved(&Policy::disabled(), None);
1281        assert_eq!(alone.local.reason, Some(DisabledReason::PolicyDisabled));
1282        assert_eq!(alone.remote.reason, Some(DisabledReason::PolicyDisabled));
1283        assert!(!alone.stale_on_error_config_error);
1284        assert!(!alone.shadow.enabled && !alone.shadow.config_error);
1285        assert!(!alone.shadow.logs_mismatches() && !alone.shadow.logging_config_error);
1286
1287        // As an overlay it ramps every inherited path to zero instead of relying on omission.
1288        let base = Policy::enabled(60)
1289            .request_local(true)
1290            .stale_on_error_max_age_sec(120)
1291            .shadow(ShadowPolicy {
1292                ramp: Some(100.0),
1293                log_mismatches: Some(true),
1294                mismatch_logging: Some(MismatchLogging {
1295                    value: Some(true),
1296                    diff: Some(MismatchDiff::Values),
1297                }),
1298            });
1299        let killed = resolved(&base, Some(RuntimePolicy::from(Policy::disabled()).0));
1300        assert!(!killed.request_local);
1301        assert!(killed.coalesce);
1302        for layer in [killed.local, killed.remote] {
1303            assert!(!layer.enabled);
1304            assert_eq!(layer.reason, Some(DisabledReason::RampedDown));
1305            assert!(layer.configured);
1306            assert_eq!(layer.ttl_ms, 60_000);
1307            assert_eq!(layer.ramp, 0.0);
1308        }
1309        assert_eq!(killed.stale_on_error_max_age_ms, 0);
1310        assert!(!killed.stale_on_error_config_error);
1311        assert_eq!(killed.shadow, ResolvedShadow::default());
1312    }
1313
1314    // Port of TestRuntimePolicyFailureScopes.
1315    #[test]
1316    fn runtime_failure_scopes() {
1317        let base = Policy::default()
1318            .request_local(true)
1319            .local_ttl_sec(1)
1320            .remote_ttl_sec(2);
1321        for overlay in [
1322            json!(false),
1323            json!([]),
1324            json!("config"),
1325            json!(0),
1326            json!({"shadowRamp": 5}),
1327            json!({"ttlSec": null}),
1328            json!({"ttlSec": []}),
1329            json!({"ttlSec": 5}),
1330            json!({"ramp": null}),
1331            json!({"shadow": null}),
1332            json!({"shadow": "off"}),
1333            json!({"requestLocal": null}),
1334            json!({"requestLocal": "true"}),
1335            json!({"coalesce": 1}),
1336            json!({"coalesce": null}),
1337            json!({"remoteReadTimeoutMs": 0}),
1338            json!({"remoteReadTimeoutMs": null}),
1339            json!({"remoteReadTimeoutMs": 1.5}),
1340            json!({"remoteReadTimeoutMs": "30"}),
1341            json!({"remoteReadTimeoutMs": 2_147_483_648u64}),
1342        ] {
1343            assert!(
1344                resolve(&base, Some(overlay.clone())).is_err(),
1345                "accepted invalid invocation policy: {overlay}"
1346            );
1347        }
1348
1349        struct Case {
1350            overlay: Value,
1351            local: Option<DisabledReason>,
1352            remote: Option<DisabledReason>,
1353            recovery_error: bool,
1354        }
1355        let invalid_ttl = Some(DisabledReason::InvalidTtl);
1356        let invalid_ramp = Some(DisabledReason::InvalidRamp);
1357        let cases = [
1358            Case {
1359                overlay: json!({"ttlSec": {"local": null}}),
1360                local: invalid_ttl,
1361                remote: None,
1362                recovery_error: false,
1363            },
1364            Case {
1365                overlay: json!({"ttlSec": {"local": "1"}}),
1366                local: invalid_ttl,
1367                remote: None,
1368                recovery_error: false,
1369            },
1370            Case {
1371                overlay: json!({"ttlSec": {"local": 0}}),
1372                local: invalid_ttl,
1373                remote: None,
1374                recovery_error: false,
1375            },
1376            Case {
1377                overlay: json!({"ttlSec": {"local": 1.5}}),
1378                local: invalid_ttl,
1379                remote: None,
1380                recovery_error: false,
1381            },
1382            Case {
1383                overlay: json!({"ttlSec": {"local": 31_536_001}}),
1384                local: invalid_ttl,
1385                remote: None,
1386                recovery_error: false,
1387            },
1388            Case {
1389                overlay: json!({"ramp": {"remote": true}}),
1390                local: None,
1391                remote: invalid_ramp,
1392                recovery_error: false,
1393            },
1394            Case {
1395                overlay: json!({"ramp": {"remote": null}}),
1396                local: None,
1397                remote: invalid_ramp,
1398                recovery_error: false,
1399            },
1400            Case {
1401                overlay: json!({"ramp": {"remote": 100.1}}),
1402                local: None,
1403                remote: invalid_ramp,
1404                recovery_error: false,
1405            },
1406            Case {
1407                overlay: json!({"ramp": {"local": -1}}),
1408                local: invalid_ramp,
1409                remote: None,
1410                recovery_error: false,
1411            },
1412            Case {
1413                overlay: json!({"staleOnErrorMaxAgeSec": null}),
1414                local: None,
1415                remote: None,
1416                recovery_error: true,
1417            },
1418            Case {
1419                overlay: json!({"staleOnErrorMaxAgeSec": 1}),
1420                local: None,
1421                remote: None,
1422                recovery_error: true,
1423            },
1424            Case {
1425                overlay: json!({"staleOnErrorMaxAgeSec": 2}),
1426                local: None,
1427                remote: None,
1428                recovery_error: true,
1429            },
1430            Case {
1431                overlay: json!({"staleOnErrorMaxAgeSec": "3"}),
1432                local: None,
1433                remote: None,
1434                recovery_error: true,
1435            },
1436            Case {
1437                overlay: json!({"staleOnErrorMaxAgeSec": 2.5}),
1438                local: None,
1439                remote: None,
1440                recovery_error: true,
1441            },
1442            Case {
1443                overlay: json!({"staleOnErrorMaxAgeSec": 0}),
1444                local: None,
1445                remote: None,
1446                recovery_error: false,
1447            },
1448            Case {
1449                overlay: json!({"staleOnErrorMaxAgeSec": 0.0}),
1450                local: None,
1451                remote: None,
1452                recovery_error: false,
1453            },
1454            Case {
1455                overlay: json!({"staleOnErrorMaxAgeSec": 3}),
1456                local: None,
1457                remote: None,
1458                recovery_error: false,
1459            },
1460            Case {
1461                overlay: json!({"ttlSec": {"remote": -1}, "staleOnErrorMaxAgeSec": -1}),
1462                local: None,
1463                remote: invalid_ttl,
1464                recovery_error: false,
1465            },
1466            Case {
1467                overlay: json!({"ramp": {"remote": "bad"}, "staleOnErrorMaxAgeSec": 5}),
1468                local: None,
1469                remote: invalid_ramp,
1470                recovery_error: false,
1471            },
1472        ];
1473        for case in cases {
1474            let r = resolved(&base, Some(case.overlay.clone()));
1475            assert!(r.request_local, "{}", case.overlay);
1476            assert!(r.coalesce, "{}", case.overlay);
1477            assert_eq!(
1478                r.local.reason, case.local,
1479                "local reason for {}",
1480                case.overlay
1481            );
1482            assert_eq!(
1483                r.remote.reason, case.remote,
1484                "remote reason for {}",
1485                case.overlay
1486            );
1487            assert_eq!(r.local.enabled, case.local.is_none(), "{}", case.overlay);
1488            assert_eq!(r.remote.enabled, case.remote.is_none(), "{}", case.overlay);
1489            assert_eq!(
1490                r.stale_on_error_config_error, case.recovery_error,
1491                "recovery for {}",
1492                case.overlay
1493            );
1494            if case.recovery_error {
1495                assert_eq!(r.stale_on_error_max_age_ms, 0, "{}", case.overlay);
1496            }
1497        }
1498        // A valid runtime age above the remote TTL enables recovery.
1499        assert_eq!(
1500            resolved(&base, Some(json!({"staleOnErrorMaxAgeSec": 3}))).stale_on_error_max_age_ms,
1501            3_000
1502        );
1503
1504        // Recovery configured without any remote TTL is a diagnostic error on a disabled layer.
1505        let missing = resolved(
1506            &Policy::default(),
1507            Some(json!({"staleOnErrorMaxAgeSec": 3})),
1508        );
1509        assert!(missing.stale_on_error_config_error);
1510        assert_eq!(missing.remote.reason, Some(DisabledReason::PolicyDisabled));
1511        assert!(!missing.remote.configured);
1512        assert_eq!(missing.stale_on_error_max_age_ms, 0);
1513        // ... unless it is numeric zero.
1514        let zero = resolved(
1515            &Policy::default(),
1516            Some(json!({"staleOnErrorMaxAgeSec": 0})),
1517        );
1518        assert!(!zero.stale_on_error_config_error);
1519        // A non-numeric leaf on a disabled remote layer is still an error.
1520        let text = resolved(
1521            &Policy::default(),
1522            Some(json!({"staleOnErrorMaxAgeSec": "0"})),
1523        );
1524        assert!(text.stale_on_error_config_error);
1525    }
1526
1527    // Port of TestRecoveryRetentionAndShadowDiagnosticsRemainIndependent.
1528    #[test]
1529    fn recovery_retention_and_shadow_diagnostics_remain_independent() {
1530        let base = Policy::default()
1531            .remote_ttl_sec(1)
1532            .remote_ramp(0.0)
1533            .stale_on_error_max_age_sec(86_400);
1534        let r = resolved(
1535            &base,
1536            Some(json!({"shadow": {"ramp": 100, "logMismatches": "invalid"}})),
1537        );
1538        assert!(!r.remote.enabled);
1539        assert!(r.remote.configured);
1540        assert_eq!(r.remote.reason, Some(DisabledReason::RampedDown));
1541        assert_eq!(r.stale_on_error_max_age_ms, 86_400_000);
1542        assert!(!r.stale_on_error_config_error);
1543        assert!(r.shadow.enabled);
1544        assert_eq!(r.shadow.ramp, 100.0);
1545        assert!(!r.shadow.config_error);
1546        assert!(r.shadow.logging_config_error);
1547        assert!(!r.shadow.logs_mismatches());
1548
1549        let r = resolved(&base, Some(json!({"shadow": {"ramp": null}})));
1550        assert!(r.shadow.config_error);
1551        assert!(!r.shadow.enabled);
1552        assert_eq!(r.shadow.ramp, 0.0);
1553        assert!(r.remote.configured);
1554        assert_eq!(r.stale_on_error_max_age_ms, 86_400_000);
1555
1556        for invalid in [json!("50"), json!(-1), json!(100.5), json!(true)] {
1557            let r = resolved(&base, Some(json!({"shadow": {"ramp": invalid}})));
1558            assert!(r.shadow.config_error, "{invalid}");
1559            assert!(!r.shadow.enabled, "{invalid}");
1560        }
1561        // Zero shadow ramp is valid and silently off.
1562        let off = resolved(&base, Some(json!({"shadow": {"ramp": 0}})));
1563        assert!(!off.shadow.enabled && !off.shadow.config_error);
1564        // A null logging flag is malformed and disables warnings without touching the cohort.
1565        let logging = resolved(
1566            &base,
1567            Some(json!({"shadow": {"ramp": 100, "logMismatches": null}})),
1568        );
1569        assert!(
1570            logging.shadow.enabled
1571                && logging.shadow.logging_config_error
1572                && !logging.shadow.logs_mismatches()
1573        );
1574    }
1575
1576    #[test]
1577    fn overlay_semantics_follow_the_portable_table() {
1578        let base = Policy::enabled(10)
1579            .request_local(true)
1580            .coalesce(false)
1581            .stale_on_error_max_age_sec(30)
1582            .remote_read_timeout_ms(40)
1583            .shadow(ShadowPolicy {
1584                ramp: Some(100.0),
1585                log_mismatches: Some(true),
1586                mismatch_logging: None,
1587            });
1588
1589        // Omitted leaves inherit the operation leaf; an empty overlay changes nothing.
1590        let inherited = resolved(&base, None);
1591        assert_eq!(resolved(&base, Some(json!({}))), inherited);
1592        assert_eq!(
1593            resolved(&base, Some(json!({"ttlSec": {}, "ramp": {}, "shadow": {}}))),
1594            inherited
1595        );
1596        assert_eq!(resolved(&base, Some(Value::Null)), inherited);
1597        assert!(inherited.request_local && !inherited.coalesce);
1598        assert_eq!(inherited.remote_read_timeout_ms, 40);
1599        assert_eq!(inherited.stale_on_error_max_age_ms, 30_000);
1600        assert!(inherited.shadow.enabled && inherited.shadow.mismatch_value);
1601        assert_eq!(inherited.shadow.mismatch_diff, MismatchDiff::Values);
1602
1603        // A present leaf replaces the operation leaf, sparsely.
1604        let patched = resolved(
1605            &base,
1606            Some(json!({"ttlSec": {"local": 3}, "ramp": {"remote": 0}})),
1607        );
1608        assert_eq!(patched.local.ttl_ms, 3_000);
1609        assert!(patched.local.enabled);
1610        assert_eq!(patched.remote.ttl_ms, 10_000);
1611        assert_eq!(patched.remote.reason, Some(DisabledReason::RampedDown));
1612        assert!(patched.remote.configured);
1613        // Recovery still measures against the merged remote TTL.
1614        assert_eq!(patched.stale_on_error_max_age_ms, 30_000);
1615
1616        // An explicit null leaf is an invalid supplied value that disables only that layer.
1617        let nulled = resolved(&base, Some(json!({"ttlSec": {"remote": null}})));
1618        assert!(nulled.local.enabled);
1619        assert_eq!(nulled.remote.reason, Some(DisabledReason::InvalidTtl));
1620        assert!(!nulled.remote.configured);
1621        // The pre-existing recovery age on a layer disabled by an invalid TTL is not a config error.
1622        assert!(!nulled.stale_on_error_config_error);
1623        assert_eq!(nulled.stale_on_error_max_age_ms, 0);
1624
1625        // An invalid flag fails the whole invocation.
1626        assert!(resolve(&base, Some(json!({"coalesce": "yes"}))).is_err());
1627        assert!(resolve(&base, Some(json!({"coalesce": null}))).is_err());
1628        assert!(resolve(&base, Some(json!({"requestLocal": 0}))).is_err());
1629        // ... as does an invalid runtime read deadline.
1630        assert!(resolve(&base, Some(json!({"remoteReadTimeoutMs": -5}))).is_err());
1631        // A valid runtime flag or deadline wins over the operation.
1632        let flipped = resolved(
1633            &base,
1634            Some(json!({"coalesce": true, "requestLocal": false, "remoteReadTimeoutMs": 7})),
1635        );
1636        assert!(flipped.coalesce && !flipped.request_local);
1637        assert_eq!(flipped.remote_read_timeout_ms, 7);
1638
1639        // An invalid optional recovery age preserves remote serving with a diagnostic error.
1640        let recovery = resolved(&base, Some(json!({"staleOnErrorMaxAgeSec": 10})));
1641        assert!(recovery.remote.enabled);
1642        assert_eq!(recovery.stale_on_error_max_age_ms, 0);
1643        assert!(recovery.stale_on_error_config_error);
1644        let recovery = resolved(&base, Some(json!({"staleOnErrorMaxAgeSec": "30"})));
1645        assert!(recovery.remote.enabled && recovery.stale_on_error_config_error);
1646        // Zero disables inherited recovery silently.
1647        let off = resolved(&base, Some(json!({"staleOnErrorMaxAgeSec": 0})));
1648        assert!(off.remote.enabled);
1649        assert_eq!(off.stale_on_error_max_age_ms, 0);
1650        assert!(!off.stale_on_error_config_error);
1651        // Raising the remote TTL above the inherited age invalidates the inherited recovery.
1652        let raised = resolved(&base, Some(json!({"ttlSec": {"remote": 30}})));
1653        assert!(raised.remote.enabled && raised.stale_on_error_config_error);
1654        assert_eq!(raised.stale_on_error_max_age_ms, 0);
1655
1656        // Shadow leaves merge independently of serving leaves.
1657        let shadow = resolved(&base, Some(json!({"shadow": {"logMismatches": false}})));
1658        assert!(shadow.shadow.enabled && !shadow.shadow.logs_mismatches());
1659        let shadow = resolved(&base, Some(json!({"shadow": {"ramp": 0}})));
1660        assert!(
1661            !shadow.shadow.enabled
1662                && shadow.shadow.logs_mismatches()
1663                && !shadow.shadow.config_error
1664        );
1665        assert!(shadow.local.enabled && shadow.remote.enabled);
1666    }
1667
1668    #[test]
1669    fn runtime_leaves_can_configure_paths_the_operation_omitted() {
1670        // Runtime TTLs enable layers the operation left off, with an implied full ramp.
1671        let r = resolved(
1672            &Policy::default(),
1673            Some(json!({"ttlSec": {"local": 5, "remote": 6}})),
1674        );
1675        assert!(r.local.enabled && r.remote.enabled);
1676        assert_eq!((r.local.ramp, r.remote.ramp), (100.0, 100.0));
1677        assert_eq!((r.local.ttl_ms, r.remote.ttl_ms), (5_000, 6_000));
1678        // A ramp without a TTL leaves the layer policy-disabled.
1679        let r = resolved(&Policy::default(), Some(json!({"ramp": {"local": 100}})));
1680        assert_eq!(r.local.reason, Some(DisabledReason::PolicyDisabled));
1681        assert!(!r.local.configured);
1682        // Runtime shadow and recovery leaves attach to a runtime remote layer.
1683        let r = resolved(
1684            &Policy::default(),
1685            Some(
1686                json!({"ttlSec": {"remote": 6}, "staleOnErrorMaxAgeSec": 7, "shadow": {"ramp": 100}}),
1687            ),
1688        );
1689        assert_eq!(r.stale_on_error_max_age_ms, 7_000);
1690        assert!(r.shadow.enabled && !r.shadow.logs_mismatches());
1691        // A runtime static-policy overlay (via From<Policy>) behaves like its JSON form.
1692        let overlay = RuntimePolicy::from(Policy::default().local_ttl_sec(9));
1693        let r = resolve_policy(
1694            &Policy::default(),
1695            Some(&overlay),
1696            KEY,
1697            PolicyDefaults::default(),
1698        )
1699        .unwrap();
1700        assert!(r.local.enabled);
1701        assert_eq!(r.local.ttl_ms, 9_000);
1702        assert_eq!(r.remote.reason, Some(DisabledReason::PolicyDisabled));
1703    }
1704
1705    #[test]
1706    fn typed_runtime_overlays_preserve_omitted_flags() {
1707        let base = Policy::default().request_local(true).coalesce(false);
1708        for overlay in [Policy::default(), Policy::default().local_ttl_sec(9)] {
1709            let r = resolve_policy(
1710                &base,
1711                Some(&RuntimePolicy::from(overlay)),
1712                KEY,
1713                PolicyDefaults::default(),
1714            )
1715            .unwrap();
1716            assert!(r.request_local, "an omitted flag must inherit the baseline");
1717            assert!(!r.coalesce, "an omitted flag must inherit the baseline");
1718        }
1719
1720        let explicit = resolved(
1721            &base,
1722            Some(RuntimePolicy::from(Policy::default().request_local(false).coalesce(true)).0),
1723        );
1724        assert!(!explicit.request_local);
1725        assert!(explicit.coalesce);
1726
1727        let disabled = resolved(&base, Some(RuntimePolicy::from(Policy::disabled()).0));
1728        assert!(!disabled.request_local);
1729        assert!(
1730            !disabled.coalesce,
1731            "the kill switch leaves coalescing unset"
1732        );
1733    }
1734
1735    #[test]
1736    fn json_round_trip_preserves_omitted_and_explicit_flags() {
1737        for request_local in [None, Some(false), Some(true)] {
1738            for coalesce in [None, Some(false), Some(true)] {
1739                let policy = Policy {
1740                    request_local,
1741                    coalesce,
1742                    ..Policy::default()
1743                };
1744                assert_eq!(Policy::from_json(&policy.to_json()).unwrap(), policy);
1745            }
1746        }
1747    }
1748
1749    #[test]
1750    fn instance_defaults_and_deadline_precedence() {
1751        let base = Policy::enabled(1);
1752        // Zero instance default means the library default.
1753        let r = resolve_policy(
1754            &base,
1755            None,
1756            KEY,
1757            PolicyDefaults {
1758                remote_read_timeout_ms: 0,
1759            },
1760        )
1761        .unwrap();
1762        assert_eq!(r.remote_read_timeout_ms, DEFAULT_REMOTE_READ_TIMEOUT_MS);
1763        // The instance default applies when neither operation nor runtime supplies a budget.
1764        let r = resolve_policy(
1765            &base,
1766            None,
1767            KEY,
1768            PolicyDefaults {
1769                remote_read_timeout_ms: 20,
1770            },
1771        )
1772        .unwrap();
1773        assert_eq!(r.remote_read_timeout_ms, 20);
1774        // Operation beats instance; runtime beats operation.
1775        let op = base.clone().remote_read_timeout_ms(30);
1776        let r = resolve_policy(
1777            &op,
1778            None,
1779            KEY,
1780            PolicyDefaults {
1781                remote_read_timeout_ms: 20,
1782            },
1783        )
1784        .unwrap();
1785        assert_eq!(r.remote_read_timeout_ms, 30);
1786        let overlay = RuntimePolicy(json!({"remoteReadTimeoutMs": 40}));
1787        let r = resolve_policy(
1788            &op,
1789            Some(&overlay),
1790            KEY,
1791            PolicyDefaults {
1792                remote_read_timeout_ms: 20,
1793            },
1794        )
1795        .unwrap();
1796        assert_eq!(r.remote_read_timeout_ms, 40);
1797        // An out-of-domain instance default fails resolution before anything else.
1798        let err = resolve_policy(
1799            &base,
1800            None,
1801            KEY,
1802            PolicyDefaults {
1803                remote_read_timeout_ms: MAX_DEADLINE_MS + 1,
1804            },
1805        );
1806        assert_eq!(
1807            err.unwrap_err(),
1808            PolicyError("invalid instance read deadline".to_string())
1809        );
1810        assert!(resolve_policy(
1811            &base,
1812            None,
1813            KEY,
1814            PolicyDefaults {
1815                remote_read_timeout_ms: MAX_DEADLINE_MS
1816            }
1817        )
1818        .is_ok());
1819        // A statically invalid base fails resolution even with a valid overlay.
1820        let invalid = Policy::default().local_ttl_sec(0);
1821        assert!(resolve(&invalid, Some(json!({"ttlSec": {"local": 5}}))).is_err());
1822    }
1823
1824    #[test]
1825    fn error_messages_name_the_offending_container() {
1826        assert_eq!(
1827            Policy::from_json(&json!([])).unwrap_err(),
1828            PolicyError("DialCache defaultConfig must be an object".to_string())
1829        );
1830        assert_eq!(
1831            Policy::from_json(&json!({"ttlSec": null})).unwrap_err(),
1832            PolicyError("DialCache ttlSec must be an object".to_string())
1833        );
1834        assert_eq!(
1835            Policy::from_json(&json!({"shadowRamp": 1})).unwrap_err(),
1836            PolicyError("shadowRamp was replaced by shadow.ramp".to_string())
1837        );
1838        assert_eq!(
1839            resolve(&Policy::default(), Some(json!(1))).unwrap_err(),
1840            PolicyError("DialCache runtime config must be an object".to_string())
1841        );
1842        assert_eq!(
1843            resolve(&Policy::default(), Some(json!({"shadow": 1}))).unwrap_err(),
1844            PolicyError("DialCache shadow must be an object".to_string())
1845        );
1846        assert_eq!(
1847            resolve(&Policy::default(), Some(json!({"shadowRamp": null}))).unwrap_err(),
1848            PolicyError("shadowRamp was replaced by shadow.ramp".to_string())
1849        );
1850        assert_eq!(
1851            resolve(&Policy::default(), Some(json!({"coalesce": null}))).unwrap_err(),
1852            PolicyError("runtime coalesce must be boolean".to_string())
1853        );
1854        assert_eq!(
1855            resolve(&Policy::default(), Some(json!({"requestLocal": "x"}))).unwrap_err(),
1856            PolicyError("runtime requestLocal must be boolean".to_string())
1857        );
1858        assert_eq!(
1859            resolve(&Policy::default(), Some(json!({"remoteReadTimeoutMs": 0}))).unwrap_err(),
1860            PolicyError("invalid runtime remoteReadTimeoutMs".to_string())
1861        );
1862    }
1863
1864    fn shadow_logging(base: &Policy, overlay: Option<Value>) -> (bool, MismatchDiff, bool) {
1865        let shadow = resolved(base, overlay).shadow;
1866        (
1867            shadow.mismatch_value,
1868            shadow.mismatch_diff,
1869            shadow.logging_config_error,
1870        )
1871    }
1872
1873    fn shadow_base(log_mismatches: Option<bool>, group: Option<MismatchLogging>) -> Policy {
1874        Policy::default().shadow(ShadowPolicy {
1875            ramp: Some(100.0),
1876            log_mismatches,
1877            mismatch_logging: group,
1878        })
1879    }
1880
1881    #[test]
1882    fn mismatch_logging_resolves_the_recorded_four_row_table() {
1883        use MismatchDiff::{Off, Paths, Values};
1884        let leaves = |value, diff| Some(MismatchLogging { value, diff });
1885        // { logMismatches: true, mismatchLogging: { value: false } }, in either layer.
1886        let row = shadow_base(Some(true), leaves(Some(false), None));
1887        assert_eq!(shadow_logging(&row, None), (false, Values, false));
1888        assert_eq!(
1889            shadow_logging(
1890                &shadow_base(None, None),
1891                Some(
1892                    json!({"shadow": {"logMismatches": true, "mismatchLogging": {"value": false}}})
1893                )
1894            ),
1895            (false, Values, false)
1896        );
1897        // { logMismatches: false, mismatchLogging: { diff: "paths" } }, in either layer.
1898        let row = shadow_base(Some(false), leaves(None, Some(Paths)));
1899        assert_eq!(shadow_logging(&row, None), (false, Paths, false));
1900        assert_eq!(
1901            shadow_logging(
1902                &shadow_base(None, None),
1903                Some(
1904                    json!({"shadow": {"logMismatches": false, "mismatchLogging": {"diff": "paths"}}})
1905                )
1906            ),
1907            (false, Paths, false)
1908        );
1909        // A runtime shorthand replaces both inherited leaves.
1910        let paths = shadow_base(None, leaves(None, Some(Paths)));
1911        assert_eq!(
1912            shadow_logging(&paths, Some(json!({"shadow": {"logMismatches": false}}))),
1913            (false, Off, false)
1914        );
1915        let value_off = shadow_base(None, leaves(Some(false), None));
1916        assert_eq!(
1917            shadow_logging(&value_off, Some(json!({"shadow": {"logMismatches": true}}))),
1918            (true, Values, false)
1919        );
1920    }
1921
1922    #[test]
1923    fn mismatch_logging_leaves_merge_after_each_layer_expands() {
1924        use MismatchDiff::{Off, Paths, Values};
1925        let leaves = |value, diff| Some(MismatchLogging { value, diff });
1926        // An omitted runtime leaf inherits; a written one wins leaf by leaf.
1927        let base = shadow_base(None, leaves(Some(true), Some(Paths)));
1928        assert_eq!(
1929            shadow_logging(
1930                &base,
1931                Some(json!({"shadow": {"mismatchLogging": {"diff": "values"}}}))
1932            ),
1933            (true, Values, false)
1934        );
1935        assert_eq!(
1936            shadow_logging(
1937                &base,
1938                Some(json!({"shadow": {"mismatchLogging": {"value": false}}}))
1939            ),
1940            (false, Paths, false)
1941        );
1942        // A static shorthand is a baseline like any other pair of static leaves.
1943        let shorthand = shadow_base(Some(true), None);
1944        assert_eq!(
1945            shadow_logging(
1946                &shorthand,
1947                Some(json!({"shadow": {"mismatchLogging": {"diff": "paths"}}}))
1948            ),
1949            (true, Paths, false)
1950        );
1951        // An overlay without a shadow group, or with an empty one, keeps the static leaves.
1952        for overlay in [
1953            json!({}),
1954            json!({"shadow": {}}),
1955            json!({"shadow": {"ramp": 50}}),
1956        ] {
1957            assert_eq!(
1958                shadow_logging(&shorthand, Some(overlay)),
1959                (true, Values, false)
1960            );
1961        }
1962        // The disabled() overlay turns every inherited leaf off without a diagnostic.
1963        let all_on = shadow_base(Some(true), leaves(Some(true), Some(Values)));
1964        assert_eq!(
1965            shadow_logging(&all_on, Some(RuntimePolicy::from(Policy::disabled()).0)),
1966            (false, Off, false)
1967        );
1968    }
1969
1970    #[test]
1971    fn a_malformed_runtime_shorthand_stays_raw_for_one_config_resolution() {
1972        let inherited = shadow_base(
1973            None,
1974            Some(MismatchLogging {
1975                value: Some(false),
1976                diff: Some(MismatchDiff::Values),
1977            }),
1978        );
1979        let written = json!({"logMismatches": "yes", "mismatchLogging": {"value": true}});
1980        let base = match inherited.to_json() {
1981            Value::Object(object) => object["shadow"].as_object().unwrap().clone(),
1982            _ => unreachable!(),
1983        };
1984        // The malformed shorthand expands to both leaves off over the static
1985        // leaves, the leaf written beside it still wins, and the raw value stays.
1986        let merged = merge_shadow(&base, written.as_object().unwrap()).unwrap();
1987        assert_eq!(
1988            Value::Object(merged),
1989            json!({
1990                "ramp": 100,
1991                "logMismatches": "yes",
1992                "mismatchLogging": {"value": true, "diff": "off"},
1993            })
1994        );
1995        assert_eq!(
1996            shadow_logging(&inherited, Some(json!({"shadow": written}))),
1997            (true, MismatchDiff::Off, true)
1998        );
1999        // Any number of invalid inputs owes the same single flag.
2000        assert_eq!(
2001            shadow_logging(
2002                &inherited,
2003                Some(json!({"shadow": {
2004                    "logMismatches": null,
2005                    "mismatchLogging": {"value": "yes", "diff": "full"},
2006                }}))
2007            ),
2008            (false, MismatchDiff::Off, true)
2009        );
2010    }
2011
2012    #[test]
2013    fn mismatch_logging_resolves_each_leaf_independently() {
2014        use MismatchDiff::{Off, Paths, Values};
2015        let cases = [
2016            (json!({}), (false, Off, false)),
2017            (json!({"mismatchLogging": {}}), (false, Off, false)),
2018            (
2019                json!({"mismatchLogging": {"value": true, "diff": "paths"}}),
2020                (true, Paths, false),
2021            ),
2022            (
2023                json!({"mismatchLogging": {"value": "true", "diff": "paths"}}),
2024                (false, Paths, true),
2025            ),
2026            (
2027                json!({"mismatchLogging": {"value": null}}),
2028                (false, Off, true),
2029            ),
2030            (
2031                json!({"mismatchLogging": {"value": true, "diff": "full"}}),
2032                (true, Off, true),
2033            ),
2034            (json!({"mismatchLogging": {"diff": 1}}), (false, Off, true)),
2035            (
2036                json!({"mismatchLogging": {"diff": null}}),
2037                (false, Off, true),
2038            ),
2039            (
2040                json!({"mismatchLogging": {"diff": "toString"}}),
2041                (false, Off, true),
2042            ),
2043            (
2044                json!({"mismatchLogging": {"value": true, "diff": "constructor"}}),
2045                (true, Off, true),
2046            ),
2047            (
2048                json!({"mismatchLogging": {"diff": "Values"}}),
2049                (false, Off, true),
2050            ),
2051            (json!({"logMismatches": "yes"}), (false, Off, true)),
2052            (json!({"logMismatches": null}), (false, Off, true)),
2053            (json!({"logMismatches": 0}), (false, Off, true)),
2054            (
2055                json!({"logMismatches": "yes", "mismatchLogging": {"value": true, "diff": "values"}}),
2056                (true, Values, true),
2057            ),
2058            // Unknown members stay ignored, as on main.
2059            (
2060                json!({"mismatchLogging": {"diff": "paths", "unknown": 1}, "unknown": true}),
2061                (false, Paths, false),
2062            ),
2063        ];
2064        for (shadow, expected) in cases {
2065            assert_eq!(
2066                shadow_logging(&Policy::default(), Some(json!({"shadow": shadow.clone()}))),
2067                expected,
2068                "{shadow}"
2069            );
2070        }
2071    }
2072
2073    #[test]
2074    fn a_non_object_runtime_mismatch_logging_group_fails_resolution() {
2075        for group in [
2076            json!(null),
2077            json!([]),
2078            json!(1),
2079            json!("paths"),
2080            json!(true),
2081        ] {
2082            for base in [Policy::default(), shadow_base(Some(true), None)] {
2083                assert_eq!(
2084                    resolve(
2085                        &base,
2086                        Some(json!({"shadow": {"logMismatches": true, "mismatchLogging": group.clone()}}))
2087                    )
2088                    .unwrap_err(),
2089                    PolicyError("DialCache shadow mismatchLogging config must be an object".to_string()),
2090                    "{group}"
2091                );
2092            }
2093        }
2094    }
2095
2096    // Port of the TypeScript merge grid: the recorded rule is written out
2097    // without the code under test and checked over every combination.
2098    #[test]
2099    fn every_combination_of_shorthand_and_leaves_resolves_as_each_layer_expanded_first() {
2100        fn layer_leaves(
2101            shorthand: Option<&Value>,
2102            group: Option<&Value>,
2103        ) -> (Option<Value>, Option<Value>) {
2104            let (mut value, mut diff) = (None, None);
2105            if let Some(shorthand) = shorthand {
2106                let on = shorthand == &Value::Bool(true);
2107                value = Some(Value::Bool(on));
2108                diff = Some(Value::from(if on { "values" } else { "off" }));
2109            }
2110            if let Some(group) = group.and_then(Value::as_object) {
2111                if let Some(leaf) = group.get("value") {
2112                    value = Some(leaf.clone());
2113                }
2114                if let Some(leaf) = group.get("diff") {
2115                    diff = Some(leaf.clone());
2116                }
2117            }
2118            (value, diff)
2119        }
2120        let static_shorthands = [None, Some(true), Some(false)];
2121        let static_groups = [
2122            None,
2123            Some((None, None)),
2124            Some((Some(true), None)),
2125            Some((Some(false), None)),
2126            Some((None, Some(MismatchDiff::Paths))),
2127            Some((None, Some(MismatchDiff::Off))),
2128            Some((Some(true), Some(MismatchDiff::Values))),
2129        ];
2130        let runtime_shorthands = [
2131            None,
2132            Some(json!(true)),
2133            Some(json!(false)),
2134            Some(json!("yes")),
2135            Some(json!(null)),
2136            Some(json!(0)),
2137        ];
2138        let runtime_groups = [
2139            None,
2140            Some(json!({})),
2141            Some(json!({"value": true})),
2142            Some(json!({"value": false})),
2143            Some(json!({"diff": "paths"})),
2144            Some(json!({"diff": "values"})),
2145            Some(json!({"value": true, "diff": "off"})),
2146            Some(json!({"value": "yes"})),
2147            Some(json!({"value": null})),
2148            Some(json!({"diff": "full"})),
2149            Some(json!({"diff": "toString"})),
2150        ];
2151        let mut combinations = 0;
2152        for static_shorthand in static_shorthands {
2153            for static_group in static_groups {
2154                let base = shadow_base(
2155                    static_shorthand,
2156                    static_group.map(|(value, diff)| MismatchLogging { value, diff }),
2157                );
2158                let static_shadow = base.to_json()["shadow"].clone();
2159                let inherited = layer_leaves(
2160                    static_shadow.get("logMismatches"),
2161                    static_shadow.get("mismatchLogging"),
2162                );
2163                for runtime_shorthand in &runtime_shorthands {
2164                    for runtime_group in &runtime_groups {
2165                        let mut shadow = Map::new();
2166                        if let Some(shorthand) = runtime_shorthand {
2167                            shadow.insert("logMismatches".to_string(), shorthand.clone());
2168                        }
2169                        if let Some(group) = runtime_group {
2170                            shadow.insert("mismatchLogging".to_string(), group.clone());
2171                        }
2172                        let written =
2173                            layer_leaves(runtime_shorthand.as_ref(), runtime_group.as_ref());
2174                        let value = written.0.or(inherited.0.clone());
2175                        let diff = written.1.or(inherited.1.clone());
2176                        let valid_value = value.as_ref().is_none_or(Value::is_boolean);
2177                        let level = diff.as_ref().and_then(MismatchDiff::from_json);
2178                        let valid_diff = diff.is_none() || level.is_some();
2179                        let malformed = runtime_shorthand.as_ref().is_some_and(|s| !s.is_boolean());
2180                        let expected = (
2181                            valid_value && value == Some(Value::Bool(true)),
2182                            level.unwrap_or(MismatchDiff::Off),
2183                            malformed || !valid_value || !valid_diff,
2184                        );
2185                        let overlay = json!({"shadow": Value::Object(shadow)});
2186                        assert_eq!(
2187                            shadow_logging(&base, Some(overlay.clone())),
2188                            expected,
2189                            "static {static_shadow} runtime {overlay}"
2190                        );
2191                        combinations += 1;
2192                    }
2193                }
2194            }
2195        }
2196        assert_eq!(combinations, 3 * 7 * 6 * 11);
2197    }
2198}