Skip to main content

lash_core/runtime/effect/
validation.rs

1use std::collections::BTreeSet;
2use std::sync::Arc;
3
4use lash_trace::{
5    TraceContext, TraceEffectEnvelopeDiffEntry, TraceEffectEnvelopeDiffEvent,
6    TraceEffectEnvelopeDiffValue, TraceEvent, TraceLevel, TraceSink,
7};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use super::{RuntimeEffectControllerError, RuntimeEffectEnvelope};
12
13/// Matches the whole-body bound used by extended provider-request tracing.
14/// Values over this bound are omitted whole rather than prefix-truncated.
15const MAX_DIFF_VALUE_JSON_BYTES: usize = 2_048;
16const ERROR_SUMMARY_PATH_LIMIT: usize = 8;
17
18/// The exact serialized envelope bytes and the SHA-256 verdict derived from
19/// those same bytes.
20///
21/// Durable substrates record this value as one unit. Replay validation parses
22/// `json` only to explain a mismatch; the hash is always over `json` itself.
23#[derive(Clone, Debug, Serialize, Deserialize)]
24pub struct CanonicalRuntimeEffectEnvelope {
25    json: String,
26    hash: String,
27}
28
29impl CanonicalRuntimeEffectEnvelope {
30    pub(crate) fn capture(
31        envelope: &RuntimeEffectEnvelope,
32    ) -> Result<Self, RuntimeEffectControllerError> {
33        let json = crate::stable_hash::stable_json_string(envelope).map_err(|err| {
34            RuntimeEffectControllerError::new(
35                "runtime_effect_envelope_hash",
36                format!("failed to serialize runtime effect envelope: {err}"),
37            )
38        })?;
39        let hash = crate::stable_hash::sha256_hex(json.as_bytes());
40        Ok(Self { json, hash })
41    }
42
43    pub fn hash(&self) -> &str {
44        &self.hash
45    }
46
47    fn verify(&self, side: &str) -> Result<(), RuntimeEffectControllerError> {
48        let actual = crate::stable_hash::sha256_hex(self.json.as_bytes());
49        if actual == self.hash {
50            return Ok(());
51        }
52        Err(RuntimeEffectControllerError::new(
53            "runtime_effect_envelope_canonical_hash_invariant",
54            format!(
55                "{side} envelope hash {} was not derived from its canonical serialized form (derived {actual})",
56                self.hash
57            ),
58        ))
59    }
60
61    #[cfg(test)]
62    fn with_forced_hash(mut self, hash: impl Into<String>) -> Self {
63        self.hash = hash.into();
64        self
65    }
66}
67
68/// Compact, content-free mismatch evidence retained on the controller error.
69#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
70pub struct RuntimeEffectReplayMismatchSummary {
71    pub divergent_path_count: usize,
72    pub first_divergent_paths: Vec<String>,
73}
74
75/// Extended-trace capability for replay-validation diagnostics.
76///
77/// Construction returns `None` unless both sides of the FIG-523 gate are
78/// present: extended trace level and a configured sink.
79#[derive(Clone)]
80pub struct RuntimeEffectReplayTrace {
81    sink: Arc<dyn TraceSink>,
82    base_context: TraceContext,
83    context: TraceContext,
84    clock: Arc<dyn crate::Clock>,
85}
86
87impl RuntimeEffectReplayTrace {
88    pub(crate) fn gated(
89        level: TraceLevel,
90        sink: Option<&Arc<dyn TraceSink>>,
91        base_context: TraceContext,
92        context: TraceContext,
93        clock: Arc<dyn crate::Clock>,
94    ) -> Option<Self> {
95        if !level.is_extended() {
96            return None;
97        }
98        Some(Self {
99            sink: Arc::clone(sink?),
100            base_context,
101            context,
102            clock,
103        })
104    }
105
106    fn emit(&self, event: TraceEffectEnvelopeDiffEvent) {
107        crate::trace::emit_trace(
108            &Some(Arc::clone(&self.sink)),
109            &self.base_context,
110            self.context.clone(),
111            TraceEvent::EffectEnvelopeDiff { event },
112            self.clock.as_ref(),
113        );
114    }
115}
116
117/// Validate a reconstructed effect envelope against a substrate-recorded
118/// canonical envelope.
119///
120/// This is the shared replay-validation seam. Substrates supply only their
121/// public mismatch code; canonical comparison, summary construction, and gated
122/// diagnostics remain identical for every consumer.
123pub fn validate_replayed_effect_envelope(
124    recorded: &CanonicalRuntimeEffectEnvelope,
125    reconstructed: &CanonicalRuntimeEffectEnvelope,
126    mismatch_code: &str,
127    trace: Option<&RuntimeEffectReplayTrace>,
128) -> Result<(), RuntimeEffectControllerError> {
129    recorded.verify("recorded")?;
130    reconstructed.verify("reconstructed")?;
131
132    if recorded.hash == reconstructed.hash {
133        return Ok(());
134    }
135
136    if recorded.json == reconstructed.json {
137        return Err(RuntimeEffectControllerError::new(
138            "runtime_effect_envelope_canonical_hash_invariant",
139            "envelope hashes differed even though their canonical serialized forms were identical",
140        ));
141    }
142
143    let recorded_value: Value = serde_json::from_str(&recorded.json).map_err(|err| {
144        RuntimeEffectControllerError::new(
145            "runtime_effect_envelope_canonical_decode",
146            format!("failed to decode recorded canonical envelope: {err}"),
147        )
148    })?;
149    let reconstructed_value: Value = serde_json::from_str(&reconstructed.json).map_err(|err| {
150        RuntimeEffectControllerError::new(
151            "runtime_effect_envelope_canonical_decode",
152            format!("failed to decode reconstructed canonical envelope: {err}"),
153        )
154    })?;
155    let mut differences = Vec::new();
156    collect_differences(
157        "",
158        Some(&recorded_value),
159        Some(&reconstructed_value),
160        &mut differences,
161    );
162    if differences.is_empty() {
163        return Err(RuntimeEffectControllerError::new(
164            "runtime_effect_envelope_canonical_hash_invariant",
165            "envelope hashes differed but their canonical forms had zero divergent structural paths",
166        ));
167    }
168
169    let paths = differences
170        .iter()
171        .map(|difference| difference.path.clone())
172        .collect::<Vec<_>>();
173    let summary = RuntimeEffectReplayMismatchSummary {
174        divergent_path_count: paths.len(),
175        first_divergent_paths: paths
176            .iter()
177            .take(ERROR_SUMMARY_PATH_LIMIT)
178            .cloned()
179            .collect(),
180    };
181    if let Some(trace) = trace {
182        trace.emit(TraceEffectEnvelopeDiffEvent {
183            recorded_envelope_hash: recorded.hash.clone(),
184            reconstructed_envelope_hash: reconstructed.hash.clone(),
185            divergent_paths: differences,
186        });
187    }
188
189    Err(RuntimeEffectControllerError::new(
190        mismatch_code,
191        format!(
192            "recorded runtime effect hash {} did not match reconstructed envelope hash {}; divergent_path_count={}; divergent_paths=[{}]",
193            recorded.hash,
194            reconstructed.hash,
195            summary.divergent_path_count,
196            summary.first_divergent_paths.join(", ")
197        ),
198    )
199    .with_summary(summary))
200}
201
202fn collect_differences(
203    path: &str,
204    recorded: Option<&Value>,
205    reconstructed: Option<&Value>,
206    differences: &mut Vec<TraceEffectEnvelopeDiffEntry>,
207) {
208    match (recorded, reconstructed) {
209        (Some(Value::Object(recorded)), Some(Value::Object(reconstructed))) => {
210            let keys = recorded
211                .keys()
212                .chain(reconstructed.keys())
213                .collect::<BTreeSet<_>>();
214            for key in keys {
215                collect_differences(
216                    &field_path(path, key),
217                    recorded.get(key),
218                    reconstructed.get(key),
219                    differences,
220                );
221            }
222        }
223        (Some(Value::Array(recorded)), Some(Value::Array(reconstructed))) => {
224            for index in 0..recorded.len().max(reconstructed.len()) {
225                collect_differences(
226                    &format!("{path}[{index}]"),
227                    recorded.get(index),
228                    reconstructed.get(index),
229                    differences,
230                );
231            }
232        }
233        (Some(recorded), Some(reconstructed)) if recorded == reconstructed => {}
234        (recorded, reconstructed) => differences.push(TraceEffectEnvelopeDiffEntry {
235            path: path.to_string(),
236            recorded: trace_value(recorded),
237            reconstructed: trace_value(reconstructed),
238        }),
239    }
240}
241
242fn field_path(parent: &str, field: &str) -> String {
243    if parent.is_empty() {
244        field.to_string()
245    } else {
246        format!("{parent}.{field}")
247    }
248}
249
250fn trace_value(value: Option<&Value>) -> TraceEffectEnvelopeDiffValue {
251    let Some(value) = value else {
252        return TraceEffectEnvelopeDiffValue::Missing;
253    };
254    let json = serde_json::to_vec(value).expect("serde_json::Value always serializes");
255    let omitted = json.len() > MAX_DIFF_VALUE_JSON_BYTES;
256    TraceEffectEnvelopeDiffValue::Present {
257        json_len: json.len(),
258        json_sha256: crate::stable_hash::sha256_hex(&json),
259        value_json: (!omitted).then(|| value.clone()),
260        value_json_omitted_reason: omitted.then(|| "size_limit".to_string()),
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use std::sync::Mutex;
267
268    use lash_trace::{TraceRecord, TraceSink, TraceSinkError};
269    use serde_json::json;
270
271    use super::*;
272    use crate::{RuntimeEffectCommand, RuntimeEffectKind, RuntimeInvocation, RuntimeScope};
273
274    #[derive(Default)]
275    struct RecordingSink {
276        records: Mutex<Vec<TraceRecord>>,
277    }
278
279    impl TraceSink for RecordingSink {
280        fn append(&self, record: &TraceRecord) -> Result<(), TraceSinkError> {
281            self.records
282                .lock()
283                .expect("trace records")
284                .push(record.clone());
285            Ok(())
286        }
287    }
288
289    fn envelope(input: Value) -> RuntimeEffectEnvelope {
290        RuntimeEffectEnvelope::new(
291            RuntimeInvocation::effect(
292                RuntimeScope::for_turn("session", "turn", 0, 0),
293                "durable:test",
294                RuntimeEffectKind::DurableStep,
295                "durable:test",
296            ),
297            RuntimeEffectCommand::DurableStep {
298                step_id: "test".to_string(),
299                input,
300            },
301        )
302    }
303
304    fn canonical(input: Value) -> CanonicalRuntimeEffectEnvelope {
305        CanonicalRuntimeEffectEnvelope::capture(&envelope(input)).expect("canonical envelope")
306    }
307
308    fn mismatch_paths(recorded: Value, reconstructed: Value) -> Vec<String> {
309        let error = validate_replayed_effect_envelope(
310            &canonical(recorded),
311            &canonical(reconstructed),
312            "restate_effect_hash_mismatch",
313            None,
314        )
315        .expect_err("mismatch");
316        error.summary.expect("summary").first_divergent_paths
317    }
318
319    #[test]
320    fn reports_deep_tool_result_scalar_path() {
321        assert_eq!(
322            mismatch_paths(
323                json!({"tool_results": [{"value": {"embedding_duration_ms": 1761}}]}),
324                json!({"tool_results": [{"value": {"embedding_duration_ms": 532}}]}),
325            ),
326            ["command.input.tool_results[0].value.embedding_duration_ms"]
327        );
328    }
329
330    #[test]
331    fn reports_added_and_removed_field_paths() {
332        assert_eq!(
333            mismatch_paths(
334                json!({"tool_results": [{"kept": true, "removed": 1}]}),
335                json!({"tool_results": [{"kept": true, "added": 2}]}),
336            ),
337            [
338                "command.input.tool_results[0].added",
339                "command.input.tool_results[0].removed",
340            ]
341        );
342    }
343
344    #[test]
345    fn reports_reordered_array_element_paths() {
346        assert_eq!(
347            mismatch_paths(
348                json!({"tool_results": [{"value": ["first", "second"]}]}),
349                json!({"tool_results": [{"value": ["second", "first"]}]}),
350            ),
351            [
352                "command.input.tool_results[0].value[0]",
353                "command.input.tool_results[0].value[1]",
354            ]
355        );
356    }
357
358    #[test]
359    fn error_summary_counts_all_paths_and_keeps_first_eight() {
360        let error = validate_replayed_effect_envelope(
361            &canonical(json!({
362                "f0": 0, "f1": 0, "f2": 0, "f3": 0, "f4": 0,
363                "f5": 0, "f6": 0, "f7": 0, "f8": 0, "f9": 0
364            })),
365            &canonical(json!({
366                "f0": 1, "f1": 1, "f2": 1, "f3": 1, "f4": 1,
367                "f5": 1, "f6": 1, "f7": 1, "f8": 1, "f9": 1
368            })),
369            "restate_effect_hash_mismatch",
370            None,
371        )
372        .expect_err("mismatch");
373        assert_eq!(
374            error.summary.expect("summary"),
375            RuntimeEffectReplayMismatchSummary {
376                divergent_path_count: 10,
377                first_divergent_paths: vec![
378                    "command.input.f0".to_string(),
379                    "command.input.f1".to_string(),
380                    "command.input.f2".to_string(),
381                    "command.input.f3".to_string(),
382                    "command.input.f4".to_string(),
383                    "command.input.f5".to_string(),
384                    "command.input.f6".to_string(),
385                    "command.input.f7".to_string(),
386                ],
387            }
388        );
389    }
390
391    #[test]
392    fn large_divergent_value_is_whole_value_elided() {
393        let sink = Arc::new(RecordingSink::default());
394        let sink_dyn: Arc<dyn TraceSink> = sink.clone();
395        let trace = RuntimeEffectReplayTrace::gated(
396            TraceLevel::Extended,
397            Some(&sink_dyn),
398            TraceContext::default(),
399            TraceContext::default(),
400            Arc::new(crate::SystemClock),
401        )
402        .expect("extended trace");
403        let error = validate_replayed_effect_envelope(
404            &canonical(json!({"tool_results": [{"value": "a".repeat(3_000)}]})),
405            &canonical(json!({"tool_results": [{"value": "b".repeat(3_000)}]})),
406            "restate_effect_hash_mismatch",
407            Some(&trace),
408        )
409        .expect_err("mismatch");
410        assert_eq!(
411            error.summary.expect("summary").first_divergent_paths,
412            ["command.input.tool_results[0].value"]
413        );
414
415        let records = sink.records.lock().expect("trace records");
416        let TraceEvent::EffectEnvelopeDiff { event } = &records[0].event else {
417            panic!("expected effect-envelope diff trace");
418        };
419        assert_eq!(
420            event.divergent_paths[0].recorded,
421            TraceEffectEnvelopeDiffValue::Present {
422                json_len: 3_002,
423                json_sha256: crate::stable_hash::sha256_hex(
424                    serde_json::to_string(&"a".repeat(3_000))
425                        .expect("serialize literal")
426                        .as_bytes()
427                ),
428                value_json: None,
429                value_json_omitted_reason: Some("size_limit".to_string()),
430            }
431        );
432    }
433
434    #[test]
435    fn forced_hash_mismatch_with_identical_canonical_forms_fails_loudly() {
436        let recorded = canonical(json!({"tool_results": []})).with_forced_hash("forced");
437        let reconstructed = canonical(json!({"tool_results": []}));
438        let error = validate_replayed_effect_envelope(
439            &recorded,
440            &reconstructed,
441            "restate_effect_hash_mismatch",
442            None,
443        )
444        .expect_err("canonical invariant failure");
445        assert_eq!(
446            error.code,
447            "runtime_effect_envelope_canonical_hash_invariant"
448        );
449        assert!(error.summary.is_none());
450    }
451
452    #[test]
453    fn diff_trace_requires_extended_level_and_sink_but_summary_does_not() {
454        let sink = Arc::new(RecordingSink::default());
455        let sink_dyn: Arc<dyn TraceSink> = sink.clone();
456        for trace in [
457            RuntimeEffectReplayTrace::gated(
458                TraceLevel::Standard,
459                Some(&sink_dyn),
460                TraceContext::default(),
461                TraceContext::default(),
462                Arc::new(crate::SystemClock),
463            ),
464            RuntimeEffectReplayTrace::gated(
465                TraceLevel::Extended,
466                None,
467                TraceContext::default(),
468                TraceContext::default(),
469                Arc::new(crate::SystemClock),
470            ),
471        ] {
472            assert!(trace.is_none());
473            let error = validate_replayed_effect_envelope(
474                &canonical(json!({"value": 1})),
475                &canonical(json!({"value": 2})),
476                "restate_effect_hash_mismatch",
477                trace.as_ref(),
478            )
479            .expect_err("mismatch");
480            assert_eq!(error.summary.expect("summary").divergent_path_count, 1);
481        }
482        assert!(sink.records.lock().expect("trace records").is_empty());
483    }
484}