Skip to main content

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}