mlua_swarm/store/replay/mod.rs
1//! `ReplayStore` — per-run log of completed step outputs plus the whole
2//! [`Ctx`] snapshot captured at the moment each step passed.
3//!
4//! # Architecture
5//!
6//! This module is the Core primitive for restart-equivalent recovery: at
7//! the moment `Engine::dispatch_attempt_with_run_ctx` sees a step's Adapter
8//! dispatch complete with `DispatchOutcome::Pass(value)`, it appends a
9//! [`ReplayEntry`] carrying `(run_id, step_ref, input_hash, occurrence,
10//! ctx_snapshot_json, step_output_json)` — no Adapter-external state (WS
11//! session id, `worker_handle`, spawner registrations) is persisted. On
12//! restart, a fresh `Engine` re-runs the same Blueprint and consults a
13//! [`ReplayCursor`] built from the stored entries: a matching `(step_ref,
14//! input_hash, occurrence)` returns the stored value verbatim as
15//! `DispatchOutcome::Pass`, skipping the worker spawn entirely; a miss
16//! dispatches normally.
17//!
18//! `Ctx` carries `#[serde(skip)] operator: OperatorInfo`, so the trait-object
19//! faces (bridges, hooks, operator backends) naturally drop out of the
20//! snapshot; on the replay side, `Ctx::deserialize` rebuilds them as
21//! `OperatorInfo::default()`, and the fresh `Engine`'s registries repopulate
22//! them at dispatch time. This is the "Adapter concern stays in the
23//! Adapter" boundary the subtask brief calls out.
24//!
25//! ## Contract summary
26//!
27//! - **`ReplayEntry`** — plain data row, `Serialize`/`Deserialize` and
28//! `Clone`. `input_hash` is a hex-encoded SHA-256 over a canonicalized
29//! JSON form of the resolved input (see [`hash_input_value`]);
30//! `occurrence` counts repeated dispatches of the same
31//! `(run, step, input)` triple in order (loop bodies re-visiting the
32//! same step).
33//! - **`ReplayStore`** — async trait: `append` writes one row, `list_by_run`
34//! returns every row for a `RunId` in insertion order. The two backends
35//! shipped here are [`InMemoryReplayStore`] (default, process-volatile)
36//! and [`SqliteReplayStore`](sqlite::SqliteReplayStore) (file-backed).
37//! - **`ReplayCursor`** — the read-side helper: `from_entries` builds an
38//! in-memory index and `next_occurrence` + `find` are the two calls the
39//! dispatcher makes per attempt.
40//!
41//! ## Not in scope (this iteration)
42//!
43//! - HTTP resume trigger, CLI flags, and boot-time auto-resume — these
44//! sit above this Core primitive and are out of scope for this iteration.
45//! - Operator/worker session re-registration — the Adapter-external state
46//! is Adapter concern; the fresh factory re-mints handles/tokens.
47
48use crate::core::ctx::Ctx;
49use crate::core::state::DispatchOutcome;
50use crate::types::{now_unix, RunId};
51use async_trait::async_trait;
52use serde::{Deserialize, Serialize};
53use serde_json::Value;
54use sha2::Digest;
55use std::collections::HashMap;
56use std::sync::Mutex;
57use thiserror::Error;
58
59pub mod sqlite;
60
61pub use sqlite::SqliteReplayStore;
62
63// ──────────────────────────────────────────────────────────────────────────
64// ReplayEntry — one persisted replay row.
65// ──────────────────────────────────────────────────────────────────────────
66
67/// One persisted row in the replay log — a step's completion snapshot the
68/// dispatcher stored after `DispatchOutcome::Pass`.
69///
70/// The row's identity is the 4-tuple `(run_id, step_ref, input_hash,
71/// occurrence)`; the SQLite backend enforces this as a `UNIQUE` constraint.
72/// `ctx_snapshot_json` and `step_output_json` are the two payloads: the
73/// former is a full [`Ctx`] serde-JSON with `operator` dropped by the
74/// `#[serde(skip)]` on that field, and the latter is the `DispatchOutcome::Pass`
75/// value the worker produced (audit + the value the replay hit returns).
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ReplayEntry {
78 /// The Run this dispatch belonged to (issue #13 hierarchy).
79 pub run_id: RunId,
80 /// Blueprint step ref that was dispatched (`TaskSpec.agent` at the
81 /// engine boundary).
82 pub step_ref: String,
83 /// Deterministic hex-encoded SHA-256 hash of the resolved input value
84 /// — see [`hash_input_value`].
85 pub input_hash: String,
86 /// Zero-based counter for repeated dispatches of the same
87 /// `(run_id, step_ref, input_hash)` triple within one Run.
88 pub occurrence: u32,
89 /// Serde-JSON of the whole [`Ctx`] at the moment the step passed.
90 /// `Ctx.operator` is dropped by the `#[serde(skip)]` on that field —
91 /// see this module's doc.
92 pub ctx_snapshot_json: String,
93 /// Serde-JSON of the `DispatchOutcome::Pass` value.
94 pub step_output_json: String,
95 /// Unix epoch seconds — when this row was recorded.
96 pub created_at: u64,
97}
98
99impl ReplayEntry {
100 /// Build a fresh entry from the dispatch-completion state, encoding
101 /// `ctx` + `step_output` to their JSON representations and stamping
102 /// `created_at` to `now_unix()`. Returns a [`ReplayStoreError::Encode`]
103 /// if either serialization fails.
104 pub fn from_completion(
105 run_id: RunId,
106 step_ref: impl Into<String>,
107 input_hash: impl Into<String>,
108 occurrence: u32,
109 ctx: &Ctx,
110 step_output: &Value,
111 ) -> Result<Self, ReplayStoreError> {
112 let ctx_snapshot_json = serde_json::to_string(ctx)
113 .map_err(|e| ReplayStoreError::Encode(format!("ctx snapshot: {e}")))?;
114 let step_output_json = serde_json::to_string(step_output)
115 .map_err(|e| ReplayStoreError::Encode(format!("step output: {e}")))?;
116 Ok(Self {
117 run_id,
118 step_ref: step_ref.into(),
119 input_hash: input_hash.into(),
120 occurrence,
121 ctx_snapshot_json,
122 step_output_json,
123 created_at: now_unix(),
124 })
125 }
126
127 /// Decode the `step_output_json` back to a [`Value`] — used by the
128 /// replay-hit path in `Engine::dispatch_attempt_with_run_ctx` to hand
129 /// a `DispatchOutcome::Pass` back to the caller without touching the
130 /// Adapter.
131 pub fn decode_step_output(&self) -> Result<Value, ReplayStoreError> {
132 serde_json::from_str(&self.step_output_json)
133 .map_err(|e| ReplayStoreError::Decode(format!("step output: {e}")))
134 }
135
136 /// Decode the `ctx_snapshot_json` back to a [`Ctx`]. `Ctx.operator`
137 /// is not serialized (`#[serde(skip)]`), so the round-tripped value
138 /// carries `OperatorInfo::default()` in that field.
139 pub fn decode_ctx_snapshot(&self) -> Result<Ctx, ReplayStoreError> {
140 serde_json::from_str(&self.ctx_snapshot_json)
141 .map_err(|e| ReplayStoreError::Decode(format!("ctx snapshot: {e}")))
142 }
143}
144
145// ──────────────────────────────────────────────────────────────────────────
146// Errors.
147// ──────────────────────────────────────────────────────────────────────────
148
149/// Errors surfaced by a [`ReplayStore`] implementation.
150#[derive(Debug, Error)]
151pub enum ReplayStoreError {
152 /// The backend refused the row because its 4-tuple identity already
153 /// exists (`UNIQUE (run_id, step_ref, input_hash, occurrence)` in the
154 /// SQLite backend).
155 #[error("duplicate replay entry: run_id={run_id} step_ref={step_ref} input_hash={input_hash} occurrence={occurrence}")]
156 Duplicate {
157 /// The Run this attempt was rejected for.
158 run_id: RunId,
159 /// The step ref of the rejected attempt.
160 step_ref: String,
161 /// The input hash of the rejected attempt.
162 input_hash: String,
163 /// The occurrence counter of the rejected attempt.
164 occurrence: u32,
165 },
166 /// Encoding a Ctx/output to JSON failed while building an entry.
167 #[error("encode: {0}")]
168 Encode(String),
169 /// Decoding a stored JSON payload back to Rust types failed.
170 #[error("decode: {0}")]
171 Decode(String),
172 /// Backend-specific failure not covered by the other variants
173 /// (I/O errors, driver errors, etc.).
174 #[error("other: {0}")]
175 Other(String),
176}
177
178// ──────────────────────────────────────────────────────────────────────────
179// ReplayStore trait.
180// ──────────────────────────────────────────────────────────────────────────
181
182/// Persistence interface for the replay log — one row per completed step
183/// per Run. Backends must preserve insertion order for `list_by_run`.
184#[async_trait]
185pub trait ReplayStore: Send + Sync {
186 /// Backend name — for diagnostics/logging.
187 fn name(&self) -> &str;
188
189 /// Append one entry. Returns [`ReplayStoreError::Duplicate`] if the
190 /// backend already carries a row with the same
191 /// `(run_id, step_ref, input_hash, occurrence)`.
192 async fn append(&self, entry: ReplayEntry) -> Result<(), ReplayStoreError>;
193
194 /// Return every entry for `run_id`, in insertion order (the order the
195 /// dispatcher wrote them).
196 async fn list_by_run(&self, run_id: &RunId) -> Result<Vec<ReplayEntry>, ReplayStoreError>;
197}
198
199// ──────────────────────────────────────────────────────────────────────────
200// InMemoryReplayStore.
201// ──────────────────────────────────────────────────────────────────────────
202
203/// Process-volatile [`ReplayStore`] — the default backend. Entries are
204/// lost on restart; use [`SqliteReplayStore`](sqlite::SqliteReplayStore)
205/// when survive-restart is what the caller wants.
206#[derive(Default)]
207pub struct InMemoryReplayStore {
208 inner: Mutex<InMemoryInner>,
209}
210
211#[derive(Default)]
212struct InMemoryInner {
213 /// Every appended entry, keyed by RunId, in append order per Run.
214 by_run: HashMap<RunId, Vec<ReplayEntry>>,
215}
216
217impl InMemoryReplayStore {
218 /// Create an empty store.
219 pub fn new() -> Self {
220 Self::default()
221 }
222}
223
224#[async_trait]
225impl ReplayStore for InMemoryReplayStore {
226 fn name(&self) -> &str {
227 "in-memory"
228 }
229
230 async fn append(&self, entry: ReplayEntry) -> Result<(), ReplayStoreError> {
231 let mut inner = self.inner.lock().expect("replay store mutex poisoned");
232 let rows = inner.by_run.entry(entry.run_id.clone()).or_default();
233 if rows.iter().any(|e| {
234 e.step_ref == entry.step_ref
235 && e.input_hash == entry.input_hash
236 && e.occurrence == entry.occurrence
237 }) {
238 return Err(ReplayStoreError::Duplicate {
239 run_id: entry.run_id,
240 step_ref: entry.step_ref,
241 input_hash: entry.input_hash,
242 occurrence: entry.occurrence,
243 });
244 }
245 rows.push(entry);
246 Ok(())
247 }
248
249 async fn list_by_run(&self, run_id: &RunId) -> Result<Vec<ReplayEntry>, ReplayStoreError> {
250 let inner = self.inner.lock().expect("replay store mutex poisoned");
251 Ok(inner.by_run.get(run_id).cloned().unwrap_or_default())
252 }
253}
254
255// ──────────────────────────────────────────────────────────────────────────
256// ReplayCursor — dispatcher-side read helper.
257// ──────────────────────────────────────────────────────────────────────────
258
259/// Dispatcher-side read helper: an in-memory index of the entries loaded
260/// from a `ReplayStore` for one Run, plus the per-`(step_ref, input_hash)`
261/// occurrence counter the dispatcher bumps at each call.
262///
263/// `next_occurrence` and `find` are the two operations the dispatcher
264/// performs per attempt: the former advances the counter and returns
265/// the current occurrence value, the latter tries to resolve
266/// `(step_ref, input_hash, occurrence)` against the loaded entries.
267#[derive(Debug, Default)]
268pub struct ReplayCursor {
269 /// `(step_ref, input_hash, occurrence) → decoded step output`.
270 by_key: HashMap<(String, String, u32), Value>,
271 /// Per-`(step_ref, input_hash)` occurrence counter — bumped by
272 /// `next_occurrence` on every call.
273 seen: HashMap<(String, String), u32>,
274}
275
276impl ReplayCursor {
277 /// Build a cursor from a run's worth of entries. Entries whose
278 /// `step_output_json` fails to decode are silently skipped after a
279 /// `tracing::warn!` — a corrupt row must not prevent the rest of the
280 /// run from replaying.
281 pub fn from_entries(entries: Vec<ReplayEntry>) -> Self {
282 let mut by_key: HashMap<(String, String, u32), Value> = HashMap::new();
283 for entry in entries {
284 match entry.decode_step_output() {
285 Ok(value) => {
286 by_key.insert(
287 (
288 entry.step_ref.clone(),
289 entry.input_hash.clone(),
290 entry.occurrence,
291 ),
292 value,
293 );
294 }
295 Err(e) => {
296 tracing::warn!(
297 run_id = %entry.run_id,
298 step_ref = %entry.step_ref,
299 occurrence = entry.occurrence,
300 error = %e,
301 "ReplayCursor::from_entries: skipping row with undecodable step_output"
302 );
303 }
304 }
305 }
306 Self {
307 by_key,
308 seen: HashMap::new(),
309 }
310 }
311
312 /// Advance the occurrence counter for `(step_ref, input_hash)` and
313 /// return the value **for THIS call** (0 on the first call, 1 on the
314 /// second, and so on). Consumes one occurrence: two consecutive calls
315 /// with the same key return 0 then 1.
316 pub fn next_occurrence(&mut self, step_ref: &str, input_hash: &str) -> u32 {
317 let key = (step_ref.to_string(), input_hash.to_string());
318 let counter = self.seen.entry(key).or_insert(0);
319 let current = *counter;
320 *counter = counter.saturating_add(1);
321 current
322 }
323
324 /// Look up a stored `DispatchOutcome::Pass` value by
325 /// `(step_ref, input_hash, occurrence)`. `None` = miss (no matching
326 /// row was appended for this triple).
327 pub fn find(&self, step_ref: &str, input_hash: &str, occurrence: u32) -> Option<Value> {
328 self.by_key
329 .get(&(step_ref.to_string(), input_hash.to_string(), occurrence))
330 .cloned()
331 }
332
333 /// Number of `(step_ref, input_hash, occurrence)` entries loaded.
334 /// Handy for tests / diagnostics.
335 pub fn len(&self) -> usize {
336 self.by_key.len()
337 }
338
339 /// `true` when no entries are loaded — every `find` will miss.
340 pub fn is_empty(&self) -> bool {
341 self.by_key.is_empty()
342 }
343}
344
345// ──────────────────────────────────────────────────────────────────────────
346// hash_input_value — deterministic input hash.
347// ──────────────────────────────────────────────────────────────────────────
348
349/// Compute a deterministic hex-encoded SHA-256 of a resolved input value
350/// (`TaskSpec.initial_directive` at the engine boundary).
351///
352/// The hash is over the canonical JSON serialization produced by
353/// `serde_json::to_string(&value)`. `serde_json` preserves the object-key
354/// insertion order that `Value::Object` (backed by `serde_json::Map`,
355/// which is `IndexMap` by default) already carries — so identical `Value`s
356/// hash identically. Callers pass the SAME `Value` shape across the
357/// original run and the replay run; nothing in this hash function
358/// canonicalizes further, and that is by design: the replay contract is
359/// value-identity, not semantic equivalence.
360pub fn hash_input_value(value: &Value) -> String {
361 let s = serde_json::to_string(value).unwrap_or_else(|_| String::new());
362 let mut hasher = sha2::Sha256::new();
363 hasher.update(s.as_bytes());
364 hex::encode(hasher.finalize())
365}
366
367// ──────────────────────────────────────────────────────────────────────────
368// Convenience — outcome → replay entry (used by the dispatcher).
369// ──────────────────────────────────────────────────────────────────────────
370
371/// If `outcome` is `DispatchOutcome::Pass(value)`, borrow the value for
372/// [`ReplayEntry::from_completion`]; every other outcome (Blocked / err)
373/// returns `None` — the dispatcher intentionally does not log those rows
374/// to prevent partial-state poisoning of the replay log.
375pub fn pass_value(outcome: &DispatchOutcome) -> Option<&Value> {
376 match outcome {
377 DispatchOutcome::Pass(v) => Some(v),
378 _ => None,
379 }
380}
381
382// ──────────────────────────────────────────────────────────────────────────
383// Unit tests.
384// ──────────────────────────────────────────────────────────────────────────
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use crate::core::ctx::{Ctx, OperatorInfo};
390 use crate::types::StepId;
391 use serde_json::json;
392
393 fn mk_ctx() -> Ctx {
394 let mut ctx = Ctx::new(StepId::new(), 1, "step-a");
395 ctx.meta
396 .runtime
397 .insert("worker_handle".into(), json!("wh-abcd1234"));
398 ctx.meta
399 .observer
400 .insert("trace_id".into(), json!("trace-42"));
401 ctx.meta.authz.insert("who".into(), json!("op-1"));
402 ctx.meta.loop_ns.insert("iter".into(), json!(0));
403 ctx
404 }
405
406 #[test]
407 fn ctx_serde_round_trip_drops_operator_but_keeps_meta() {
408 let mut ctx = mk_ctx();
409 // Set a non-default operator id to prove operator field is skipped.
410 ctx.operator = OperatorInfo {
411 id: "some-operator".to_string(),
412 ..OperatorInfo::default()
413 };
414
415 let s = serde_json::to_string(&ctx).expect("serialize ctx");
416 assert!(
417 !s.contains("some-operator"),
418 "operator field must be dropped by #[serde(skip)]"
419 );
420
421 let back: Ctx = serde_json::from_str(&s).expect("deserialize ctx");
422 assert_eq!(back.task_id, ctx.task_id);
423 assert_eq!(back.attempt, ctx.attempt);
424 assert_eq!(back.agent, ctx.agent);
425 assert_eq!(back.meta.runtime, ctx.meta.runtime);
426 assert_eq!(back.meta.authz, ctx.meta.authz);
427 assert_eq!(back.meta.observer, ctx.meta.observer);
428 assert_eq!(back.meta.loop_ns, ctx.meta.loop_ns);
429 // operator round-trips to `OperatorInfo::default()` — the `id`
430 // slot's default is `"default-automate"` (see `ctx.rs`) and the
431 // three trait-object faces are `None`.
432 let default_op = OperatorInfo::default();
433 assert_eq!(back.operator.id, default_op.id);
434 assert!(back.operator.senior_bridge.is_none());
435 assert!(back.operator.spawn_hook.is_none());
436 assert!(back.operator.operator.is_none());
437 }
438
439 #[tokio::test]
440 async fn inmemory_append_and_list_preserves_order() {
441 let store = InMemoryReplayStore::new();
442 let run_id = RunId::new();
443 let ctx = mk_ctx();
444 let e1 = ReplayEntry::from_completion(
445 run_id.clone(),
446 "step-a",
447 "hash-1",
448 0,
449 &ctx,
450 &json!({ "out": 1 }),
451 )
452 .unwrap();
453 let e2 = ReplayEntry::from_completion(
454 run_id.clone(),
455 "step-b",
456 "hash-2",
457 0,
458 &ctx,
459 &json!({ "out": 2 }),
460 )
461 .unwrap();
462 store.append(e1.clone()).await.unwrap();
463 store.append(e2.clone()).await.unwrap();
464
465 let listed = store.list_by_run(&run_id).await.unwrap();
466 assert_eq!(listed.len(), 2);
467 assert_eq!(listed[0].step_ref, "step-a");
468 assert_eq!(listed[1].step_ref, "step-b");
469 assert_eq!(listed[0].decode_step_output().unwrap(), json!({ "out": 1 }));
470 assert_eq!(listed[1].decode_step_output().unwrap(), json!({ "out": 2 }));
471 }
472
473 #[tokio::test]
474 async fn inmemory_duplicate_rejected() {
475 let store = InMemoryReplayStore::new();
476 let run_id = RunId::new();
477 let ctx = mk_ctx();
478 let e =
479 ReplayEntry::from_completion(run_id.clone(), "step-a", "hash-1", 0, &ctx, &json!("v"))
480 .unwrap();
481 store.append(e.clone()).await.unwrap();
482 let err = store.append(e).await.unwrap_err();
483 assert!(matches!(err, ReplayStoreError::Duplicate { .. }));
484 }
485
486 #[tokio::test]
487 async fn inmemory_list_by_run_isolates_runs() {
488 let store = InMemoryReplayStore::new();
489 let r1 = RunId::new();
490 let r2 = RunId::new();
491 let ctx = mk_ctx();
492 store
493 .append(ReplayEntry::from_completion(r1.clone(), "a", "h", 0, &ctx, &json!(1)).unwrap())
494 .await
495 .unwrap();
496 store
497 .append(ReplayEntry::from_completion(r2.clone(), "a", "h", 0, &ctx, &json!(2)).unwrap())
498 .await
499 .unwrap();
500 assert_eq!(store.list_by_run(&r1).await.unwrap().len(), 1);
501 assert_eq!(store.list_by_run(&r2).await.unwrap().len(), 1);
502 assert_eq!(
503 store.list_by_run(&r1).await.unwrap()[0]
504 .decode_step_output()
505 .unwrap(),
506 json!(1)
507 );
508 }
509
510 #[tokio::test]
511 async fn cursor_from_entries_hit_and_miss() {
512 let store = InMemoryReplayStore::new();
513 let run_id = RunId::new();
514 let ctx = mk_ctx();
515 store
516 .append(
517 ReplayEntry::from_completion(
518 run_id.clone(),
519 "step-a",
520 "hash-a",
521 0,
522 &ctx,
523 &json!({ "value": "stored-a" }),
524 )
525 .unwrap(),
526 )
527 .await
528 .unwrap();
529
530 let entries = store.list_by_run(&run_id).await.unwrap();
531 let cursor = ReplayCursor::from_entries(entries);
532
533 assert_eq!(cursor.len(), 1);
534 assert_eq!(
535 cursor.find("step-a", "hash-a", 0),
536 Some(json!({ "value": "stored-a" }))
537 );
538 assert!(cursor.find("step-a", "hash-a", 1).is_none());
539 assert!(cursor.find("step-b", "hash-a", 0).is_none());
540 assert!(cursor.find("step-a", "hash-b", 0).is_none());
541 }
542
543 #[test]
544 fn cursor_next_occurrence_increments_per_key() {
545 let mut cursor = ReplayCursor::default();
546 assert_eq!(cursor.next_occurrence("a", "h"), 0);
547 assert_eq!(cursor.next_occurrence("a", "h"), 1);
548 assert_eq!(cursor.next_occurrence("a", "h"), 2);
549 // Different key starts its own counter.
550 assert_eq!(cursor.next_occurrence("b", "h"), 0);
551 assert_eq!(cursor.next_occurrence("a", "h2"), 0);
552 }
553
554 #[tokio::test]
555 async fn occurrence_1_replay_row_coexists_with_occurrence_0() {
556 // The occurrence counter is what lets the dispatcher log a second
557 // dispatch of the SAME (run, step, input) without hitting the
558 // UNIQUE constraint. The InMemory store verifies the same rule.
559 let store = InMemoryReplayStore::new();
560 let run_id = RunId::new();
561 let ctx = mk_ctx();
562 let e0 = ReplayEntry::from_completion(
563 run_id.clone(),
564 "step-a",
565 "hash-a",
566 0,
567 &ctx,
568 &json!("first"),
569 )
570 .unwrap();
571 let e1 = ReplayEntry::from_completion(
572 run_id.clone(),
573 "step-a",
574 "hash-a",
575 1,
576 &ctx,
577 &json!("second"),
578 )
579 .unwrap();
580 store.append(e0).await.unwrap();
581 store
582 .append(e1)
583 .await
584 .expect("occurrence=1 must not collide with occurrence=0");
585
586 let entries = store.list_by_run(&run_id).await.unwrap();
587 assert_eq!(entries.len(), 2);
588 let cursor = ReplayCursor::from_entries(entries);
589 assert_eq!(cursor.find("step-a", "hash-a", 0), Some(json!("first")));
590 assert_eq!(cursor.find("step-a", "hash-a", 1), Some(json!("second")));
591 }
592
593 #[test]
594 fn hash_input_value_deterministic_for_same_json() {
595 let v1 = json!({ "a": 1, "b": [2, 3] });
596 let v2 = json!({ "a": 1, "b": [2, 3] });
597 assert_eq!(hash_input_value(&v1), hash_input_value(&v2));
598
599 // Different value → different hash.
600 let v3 = json!({ "a": 2, "b": [2, 3] });
601 assert_ne!(hash_input_value(&v1), hash_input_value(&v3));
602 }
603
604 #[test]
605 fn pass_value_filters_non_pass_outcomes() {
606 assert!(pass_value(&DispatchOutcome::Pass(json!("ok"))).is_some());
607 assert!(pass_value(&DispatchOutcome::Blocked(json!("no"))).is_none());
608 assert!(pass_value(&DispatchOutcome::Cancelled).is_none());
609 assert!(pass_value(&DispatchOutcome::Timeout).is_none());
610 }
611}