Pause for approval
In this guide you build a wire transfer that waits for a person before it sends any money. You:
- add an interrupt boundary, with a reject route, to a graph;
- keep its checkpoints in a SQLite file, so a pause survives a restart;
- run the graph until it pauses;
- find the paused threads and show a reviewer what each one is waiting for;
- approve one, with a corrected amount, and reject another;
- add a time limit to the approval.
How a pause works, and what a HumanSignal holds, is on Human-in-the-loop. This guide puts it to work.
Before you start
Section titled “Before you start”- Install Strata as shown in Installation. In .NET you need
Datalium.Strata.Storage(orDatalium.Strata.Hosting, which includes it). In Rust you needdatalium-strataanddatalium-strata-memory, plusserdeandserde_jsonfor your state type. - To try the CLI steps, install the
strataCLI.
The code follows the 05-hitl-interruption demo, in C# and Rust. bank stands for your payment client.
Add an interrupt boundary
Section titled “Add an interrupt boundary”Put the boundary on the node that prepares the risky step. The pause comes after that node, so the reviewer sees what it prepared, and the node after the boundary does the irreversible work.
using Datalium.Strata.Graph;using Datalium.Strata.Interruption;
const decimal TransferLimit = 10_000m;
var graph = new StateGraph<WireTransferState>() .AddNode("PrepareWireTransfer", state => state with { InvoiceGenerated = true, Status = "awaiting approval" }) .AddInterruptBoundary("PrepareWireTransfer", onReject: "CancelWireTransfer") .AddNode("ExecuteWireTransfer", (state, ctx, _) => { var decision = ctx.Decision ?? throw new InvalidOperationException("a transfer must not run without a decision");
// The world may have changed while the thread waited. Check again before the irreversible step. if (state.Amount <= 0 || state.Amount > TransferLimit) { return ValueTask.FromResult(state with { Status = "held: amount outside the limit", ApprovedBy = decision.Reviewer }); }
var reference = bank.Send(state.Beneficiary, state.Amount, state.Currency); return ValueTask.FromResult(state with { Status = $"sent ({reference})", ApprovedBy = decision.Reviewer }); }) .AddNode("CancelWireTransfer", (state, ctx, _) => ValueTask.FromResult(state with { Status = $"cancelled by {ctx.Decision!.Reviewer}" })) .AddEdge("PrepareWireTransfer", "ExecuteWireTransfer") .Compile();
sealed record WireTransferState( string Beneficiary, decimal Amount, string Currency, bool InvoiceGenerated, string Status, string? ApprovedBy, string? Note){ public static WireTransferState New(string beneficiary, decimal amount) => new(beneficiary, amount, "USD", InvoiceGenerated: false, Status: "new", ApprovedBy: null, Note: null);}use datalium_strata::StrataError;use datalium_strata::checkpoint::{GraphExecutionResult, HumanSignal};use datalium_strata::context::NodeContext;use datalium_strata::graph::StateGraph;use serde::{Deserialize, Serialize};
const TRANSFER_LIMIT: f64 = 10_000.0;
#[derive(Debug, Clone, Serialize, Deserialize)]struct WireTransferState { beneficiary: String, amount: f64, currency: String, invoice_generated: bool, status: String, approved_by: Option<String>, note: Option<String>,}
let graph = StateGraph::<WireTransferState>::new() .add_node("PrepareWireTransfer", |s: WireTransferState| WireTransferState { invoice_generated: true, status: "awaiting approval".to_string(), ..s }) .add_interrupt_boundary_with_reject("PrepareWireTransfer", "CancelWireTransfer") .add_async_node("ExecuteWireTransfer", move |s: WireTransferState, ctx: NodeContext| { let decision = ctx.decision().expect("a transfer must not run without a decision"); let approved_by = Some(decision.reviewer.clone());
// The world may have changed while the thread waited. Check again before the irreversible step. if s.amount <= 0.0 || s.amount > TRANSFER_LIMIT { return std::future::ready(WireTransferState { status: "held: amount outside the limit".to_string(), approved_by, ..s }); }
let reference = bank.send(&s.beneficiary, s.amount, &s.currency); std::future::ready(WireTransferState { status: format!("sent ({reference})"), approved_by, ..s }) }) .add_async_node("CancelWireTransfer", |s: WireTransferState, ctx: NodeContext| { let decision = ctx.decision().expect("a rejection is a decision"); std::future::ready(WireTransferState { status: format!("cancelled by {}", decision.reviewer), ..s }) }) .add_edge("PrepareWireTransfer", "ExecuteWireTransfer") .compile() .expect("graph is structurally valid");Three things in this graph matter:
- The reject route.
onRejectin C#,add_interrupt_boundary_with_rejectin Rust, sends a rejection toCancelWireTransfer, soExecuteWireTransfernever sees one. Without a route, a rejection continues at the next node, which must then readIsApproved/is_approveditself. UseAddInterruptBoundary("PrepareWireTransfer")/add_interrupt_boundary("PrepareWireTransfer")for a boundary with no route. - The decision. A resume records who answered. Every node after the boundary reads it with
ctx.Decision/ctx.decision(), so the audit trail doesn’t depend on a field the reviewer fills in. - The check. Strata doesn’t re-check the state when a thread resumes, even after days. A reviewer may also have changed a field. Check what matters in the first node after the boundary, as
ExecuteWireTransferdoes with the limit. See Known limitations.
Open a durable store
Section titled “Open a durable store”A paused thread lives only in the checkpoint store. With the in-memory store, a restart loses it. SQLite is the simplest store that keeps it: one file, created with its tables the first time you open it.
using Datalium.Strata.Storage.Memory;using Microsoft.EntityFrameworkCore;
static EfCoreCheckpointStore OpenStore(string path) => new(new CheckpointDbContext(new DbContextOptionsBuilder<CheckpointDbContext>() .UseSqlite($"Data Source={path}") .Options));use datalium_strata_memory::checkpoint::SqliteCheckpointStore;
let store = Arc::new(SqliteCheckpointStore::open("checkpoints.db")?);EfCoreCheckpointStore is the SQLite store in .NET, and it is disposable. A SQLite file suits the processes of one machine. For many writers, use Postgres. Both engines write the same file layout, so the strata CLI can read it, and a Rust service can resume a thread that a .NET service paused.
Run until it pauses
Section titled “Run until it pauses”Run the graph as usual. It runs PrepareWireTransfer, writes an interrupted checkpoint, and returns Interrupted.
using (var store = OpenStore("checkpoints.db")){ var result = await graph.RunAsync("wire-acme-corp", WireTransferState.New("Acme Corp", 4200.50m), store); if (result is GraphExecutionResult<WireTransferState>.Interrupted paused) { Console.WriteLine($"{paused.ThreadId}: paused after {paused.SuspendedNode}, continues at {paused.NextNode}"); }}let result = graph .run("wire-acme-corp", WireTransferState::new("Acme Corp", 4200.50), store.clone()) .await;if let GraphExecutionResult::Interrupted { thread_id, suspended_node, next_node, .. } = result { println!("{thread_id}: paused after {suspended_node}, continues at {next_node}");}wire-acme-corp: paused after PrepareWireTransfer, continues at ExecuteWireTransferInterrupted also carries FrozenState / frozen_state, the state as the boundary node left it. Run a second transfer, wire-globex for 9,800.00 USD, the same way. Then let the process stop. Nothing of either thread stays in memory.
Find the paused threads
Section titled “Find the paused threads”Later, maybe in another process, open the same file and ask the store for the threads in the interrupted state:
using var store = OpenStore("checkpoints.db");var page = await store.ListThreadsAsync(new ThreadQuery{ States = new HashSet<CheckpointThreadState> { CheckpointThreadState.Interrupted },});foreach (var thread in page.Threads){ Console.WriteLine($"{thread.ThreadId}: waiting at {string.Join("/", thread.WaitingAt)}, continues at {thread.NextNode}");}use datalium_strata::store::{CheckpointQuery, ThreadQuery, ThreadState};
let store = Arc::new(SqliteCheckpointStore::open("checkpoints.db")?);let query = ThreadQuery::default().with_states([ThreadState::Interrupted]);let page = store.list_threads(&query).await?;for thread in &page.threads { println!( "{}: waiting at {}, continues at {}", thread.thread_id, thread.waiting_at.join("/"), thread.next_node.as_deref().unwrap_or("-") );}wire-globex: waiting at PrepareWireTransfer, continues at ExecuteWireTransferwire-acme-corp: waiting at PrepareWireTransfer, continues at ExecuteWireTransferThe list is newest first, one page at a time: 100 threads unless you set Limit / with_limit. When NextCursor / next_cursor isn’t null, pass it back as Cursor / with_cursor for the next page. A thread already answered is in the resumed state, so it drops out of this list. Threads and checkpoints lists the other filters.
From a terminal, the CLI shows the same threads (strata thread list):
strata thread list --storage checkpoints.db --status interrupted+----------------+---------------------+-------------+-------------+-------------+----------------------+| Thread ID | Current Node | Sequence ID | State | Waiting For | Updated At |+----------------+---------------------+-------------+-------------+-------------+----------------------+| wire-globex | PrepareWireTransfer | 0 | interrupted | - | 2026-10-30T14:03:30Z |+----------------+---------------------+-------------+-------------+-------------+----------------------+| wire-acme-corp | PrepareWireTransfer | 0 | interrupted | - | 2026-10-30T14:03:30Z |+----------------+---------------------+-------------+-------------+-------------+----------------------+Waiting For stays - for a boundary without a timeout. A boundary with one shows human until <time>.
Show the reviewer what is waiting
Section titled “Show the reviewer what is waiting”The reviewer needs the state the boundary node prepared. Load the thread’s latest checkpoint and turn its frozen state, which is JSON, back into your state type:
using System.Text.Json;
var checkpoint = await store.LoadAsync("wire-acme-corp");var pending = checkpoint!.FrozenState.Deserialize<WireTransferState>()!;Console.WriteLine($"Review: {pending.Amount} {pending.Currency} to {pending.Beneficiary}");use datalium_strata::store::CheckpointStore;
let checkpoint = store.load("wire-acme-corp").await?.expect("the thread has a checkpoint");let pending: WireTransferState = serde_json::from_value(checkpoint.frozen_state)?;println!("Review: {} {} to {}", pending.amount, pending.currency, pending.beneficiary);To look at one by hand, strata thread inspect wire-acme-corp --storage checkpoints.db prints the same state, the current node and the next node.
Approve, with a correction
Section titled “Approve, with a correction”Answer the thread with a HumanSignal and ResumeAsync / resume. Use the same compiled graph and the same store file. Here the reviewer approves and corrects the amount to the invoice total:
var approval = HumanSignal.Approve("jdoe@acme.io").WithOverride("Amount", 4150.25m);var approved = await graph.ResumeAsync("wire-acme-corp", approval, store);if (approved is GraphExecutionResult<WireTransferState>.Completed done){ Console.WriteLine($"{done.State.Status}: {done.State.Amount} {done.State.Currency}, approved by {done.State.ApprovedBy}");}let approval = HumanSignal::approve("jdoe@acme.io").with_override("amount", 4150.25);if let GraphExecutionResult::Completed(done) = graph.resume("wire-acme-corp", approval, store.clone()).await{ println!( "{}: {} {}, approved by {}", done.status, done.amount, done.currency, done.approved_by.unwrap_or_default() );}sent (ref 0001): 4150.25 USD, approved by jdoe@acme.ioThe reviewer is an identity, such as an email address. Strata saves it in every later checkpoint, so never put a credential there.
An override replaces one top-level field of the saved state before any node runs. Its key is the field’s name in the state’s JSON: Amount for the C# record, amount for the Rust struct. WithOverride / with_override writes a typed JSON value, here a number, which fits a decimal or f64 field. ExecuteWireTransfer sees the corrected amount and reads the reviewer from the decision. If the reviewer had set the amount above the limit, the check would have held the transfer instead.
Or reject it
Section titled “Or reject it”A rejection takes the reject route. It can carry overrides too, for example a reason:
var rejection = HumanSignal.Reject("mlee@acme.io").WithOverride("Note", "beneficiary not on the vendor list");var rejected = await graph.ResumeAsync("wire-globex", rejection, store);if (rejected is GraphExecutionResult<WireTransferState>.Completed cancelled){ Console.WriteLine($"{cancelled.State.Status}: {cancelled.State.Note}");}let rejection = HumanSignal::reject("mlee@acme.io") .with_override("note", "beneficiary not on the vendor list");if let GraphExecutionResult::Completed(cancelled) = graph.resume("wire-globex", rejection, store.clone()).await{ println!("{}: {}", cancelled.status, cancelled.note.unwrap_or_default());}cancelled by mlee@acme.io: beneficiary not on the vendor listExecuteWireTransfer never runs for this thread, so nothing reaches the bank.
When a resume is refused
Section titled “When a resume is refused”A waiting checkpoint can be answered once. Answer wire-acme-corp a second time and the resume returns Failed:
var again = await graph.ResumeAsync("wire-acme-corp", HumanSignal.Approve("jdoe@acme.io"), store);if (again is GraphExecutionResult<WireTransferState>.Failed refused){ Console.WriteLine($"Refused: {refused.Message}");}let again = HumanSignal::approve("jdoe@acme.io");if let GraphExecutionResult::Failed(StrataError::ExecutionAborted(message)) = graph.resume("wire-acme-corp", again, store.clone()).await{ println!("Refused: {message}");}Refused: checkpoint for thread 'wire-acme-corp' was already resumedA refused resume writes nothing and runs no node. The same happens when an override doesn’t fit the state type, such as a string for the amount: the message starts with failed to rehydrate state for thread, and the thread keeps waiting, so you can answer it again with a corrected signal. What a resume refuses lists every case. To give up on a thread whose answer will never come, end it, as described in Ending a waiting thread.
Add an approval timeout
Section titled “Add an approval timeout”To wait for the reviewer for a limited time, give the boundary a timeout and a node to continue at when it passes. In Rust, wake_due takes the current time as a chrono DateTime<Utc>, so add the chrono crate.
.AddInterruptBoundary( "PrepareWireTransfer", onReject: "CancelWireTransfer", timeout: TimeSpan.FromHours(24), onTimeout: "EscalateWireTransfer")// ....AddNode("EscalateWireTransfer", state => state with { Status = "escalated: no answer within 24 h" })
// On a schedule:var report = await graph.WakeDueAsync(store);foreach (var woken in report.Woken){ Console.WriteLine($"{woken.ThreadId}: {woken.Reason}");}use chrono::Utc;use datalium_strata::wait::BoundaryOptions;use std::time::Duration;
.add_interrupt_boundary_with( "PrepareWireTransfer", BoundaryOptions::new() .with_reject("CancelWireTransfer") .with_timeout(Duration::from_secs(24 * 3600)) .with_on_timeout("EscalateWireTransfer"),)// ....add_node("EscalateWireTransfer", |s: WireTransferState| WireTransferState { status: "escalated: no answer within 24 h".to_string(), ..s})
// On a schedule:let report = graph.wake_due(store.clone(), Utc::now()).await.expect("the store can be read");for woken in &report.woken { println!("{}: {:?}", woken.thread_id, woken.reason);}Once the 24 hours have passed, the next wake call reports the thread with the reason Timeout, and the thread continues at EscalateWireTransfer. A timeout records no decision. With no timeout route, the thread is ended with the reason timed_out. Until a wake call acts on the timeout, a reviewer can still approve or reject the thread.
Nothing fires by itself. A timeout acts only when something calls WakeDueAsync / wake_due for that store. Call it on a schedule, or let the hosting package’s wake driver do it. See Wakes and timers and The hosting drivers.
Related
Section titled “Related”- Human-in-the-loop: how a pause works, and every part of a human signal.
- Threads and checkpoints: thread states and querying a store.
- Durable execution: crashes in the middle of a run, and waking threads on a schedule.
- CLI reference:
strata thread listandstrata thread inspect. - The
05-hitl-interruptionand19-waits-and-timersdemos, in C# and Rust.