Skip to content
These docs cover Strata 0.9.0-preview.1.It is a preview, and the API can still change before 1.0.

Pause for approval

In this guide you build a wire transfer that waits for a person before it sends any money. You:

  1. add an interrupt boundary, with a reject route, to a graph;
  2. keep its checkpoints in a SQLite file, so a pause survives a restart;
  3. run the graph until it pauses;
  4. find the paused threads and show a reviewer what each one is waiting for;
  5. approve one, with a corrected amount, and reject another;
  6. 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.

  • Install Strata as shown in Installation. In .NET you need Datalium.Strata.Storage (or Datalium.Strata.Hosting, which includes it). In Rust you need datalium-strata and datalium-strata-memory, plus serde and serde_json for your state type.
  • To try the CLI steps, install the strata CLI.

The code follows the 05-hitl-interruption demo, in C# and Rust. bank stands for your payment client.

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);
}

Three things in this graph matter:

  • The reject route. onReject in C#, add_interrupt_boundary_with_reject in Rust, sends a rejection to CancelWireTransfer, so ExecuteWireTransfer never sees one. Without a route, a rejection continues at the next node, which must then read IsApproved / is_approved itself. Use AddInterruptBoundary("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 ExecuteWireTransfer does with the limit. See Known limitations.

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));

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 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}");
}
}
wire-acme-corp: paused after PrepareWireTransfer, continues at ExecuteWireTransfer

Interrupted 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.

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}");
}
wire-globex: waiting at PrepareWireTransfer, continues at ExecuteWireTransfer
wire-acme-corp: waiting at PrepareWireTransfer, continues at ExecuteWireTransfer

The 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):

Terminal window
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>.

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}");

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.

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}");
}
sent (ref 0001): 4150.25 USD, approved by jdoe@acme.io

The 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.

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}");
}
cancelled by mlee@acme.io: beneficiary not on the vendor list

ExecuteWireTransfer never runs for this thread, so nothing reaches the bank.

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}");
}
Refused: checkpoint for thread 'wire-acme-corp' was already resumed

A 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.

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}");
}

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.