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
13const MAX_DIFF_VALUE_JSON_BYTES: usize = 2_048;
16const ERROR_SUMMARY_PATH_LIMIT: usize = 8;
17
18#[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#[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#[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
117pub 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}