Use typed human signals
When a person answers a paused thread, they often change something too: a number, a flag, a list, a whole plan. In this guide you send those changes as typed overrides on a HumanSignal, see how Strata checks them against your state type, check business rules after the boundary, read the recorded HumanDecision, and build a signal from an HTTP request.
Pause for approval covers the boundary, the reject route, approving and rejecting. This guide starts where it stops.
Before you start
Section titled “Before you start”- Install Strata as shown in Installation. In .NET, add
Datalium.Strata.Hosting(orDatalium.StrataandDatalium.Strata.Storage). In Rust, adddatalium-strata,datalium-strata-memory,serdeandserde_json. - Read Human-in-the-loop, which explains the parts of a signal.
The example scales a service. Propose prepares the change, the graph pauses, and Apply carries it out. The 05-hitl-interruption demo, in C# and Rust, uses the same calls on a wire transfer.
using System.Text.Json;using System.Text.Json.Nodes;using Datalium.Strata.Graph;using Datalium.Strata.Interruption;
const int MaxReplicas = 20;
var graph = new StateGraph<ScaleChange>() .AddNode("Propose", state => state with { Status = "awaiting approval" }) .AddInterruptBoundary("Propose", onReject: "Cancel") .AddFallibleNode("Apply", (state, ctx, _) => { var decision = ctx.Decision ?? throw NodeException.Fatal("no decision in effect");
// The overrides are already in the state. Check the rules the type can't express. if (state.Replicas is < 1 or > MaxReplicas) { throw NodeException.Fatal($"replicas must be between 1 and {MaxReplicas}, got {state.Replicas}"); }
if (state.Regions.Count == 0) { throw NodeException.Fatal("at least one region is required"); }
return ValueTask.FromResult(state with { Status = $"applied, approved by {decision.Reviewer}" }); }) .AddNode("Cancel", (state, ctx, _) => ValueTask.FromResult(state with { Status = $"cancelled by {ctx.Decision!.Reviewer}" })) .AddEdge("Propose", "Apply") .Compile();
sealed record ScaleChange( string Service, int Replicas, bool DryRun, RolloutPlan Plan, List<string> Regions, string? Note, string Status);
sealed record RolloutPlan(int BatchSize, List<string> Steps);use datalium_strata::checkpoint::{GraphExecutionResult, HumanSignal};use datalium_strata::context::NodeContext;use datalium_strata::graph::{NodeError, StateGraph};use serde::{Deserialize, Serialize};
const MAX_REPLICAS: u32 = 20;
#[derive(Debug, Clone, Serialize, Deserialize)]struct ScaleChange { service: String, replicas: u32, dry_run: bool, plan: RolloutPlan, regions: Vec<String>, note: Option<String>, status: String,}
#[derive(Debug, Clone, Serialize, Deserialize)]struct RolloutPlan { batch_size: u32, steps: Vec<String>,}
fn apply(s: ScaleChange, ctx: &NodeContext) -> Result<ScaleChange, NodeError> { let decision = ctx .decision() .ok_or_else(|| NodeError::Fatal("no decision in effect".to_string()))?;
// The overrides are already in the state. Check the rules the type can't express. if s.replicas < 1 || s.replicas > MAX_REPLICAS { return Err(NodeError::Fatal(format!( "replicas must be between 1 and {MAX_REPLICAS}, got {}", s.replicas ))); } if s.regions.is_empty() { return Err(NodeError::Fatal("at least one region is required".to_string())); }
Ok(ScaleChange { status: format!("applied, approved by {}", decision.reviewer), ..s })}
let graph = StateGraph::<ScaleChange>::new() .add_node("Propose", |s: ScaleChange| ScaleChange { status: "awaiting approval".to_string(), ..s }) .add_interrupt_boundary_with_reject("Propose", "Cancel") .add_async_fallible_node("Apply", |s: ScaleChange, ctx: NodeContext| { std::future::ready(apply(s, &ctx)) }) .add_async_node("Cancel", |s: ScaleChange, ctx: NodeContext| { let reviewer = ctx.decision().map(|d| d.reviewer.clone()).unwrap_or_default(); std::future::ready(ScaleChange { status: format!("cancelled by {reviewer}"), ..s }) }) .add_edge("Propose", "Apply") .compile() .expect("graph is structurally valid");Two threads, scale-checkout and scale-search, are run to the boundary against a SQLite store, as in Pause for approval. Each starts with 4 replicas, a dry run, a one-batch plan and the region eu-west.
Send typed overrides
Section titled “Send typed overrides”WithOverride(key, value) / with_override(key, value) returns the signal with one more override, so you can chain calls. Each value keeps its JSON type:
var approval = HumanSignal.Approve("alice@example.com") .WithOverride("Replicas", 6) .WithOverride("DryRun", false) .WithOverride("Plan", JsonSerializer.SerializeToNode(new RolloutPlan(2, ["drain", "scale", "verify"]))) .WithOverride("Regions", new JsonArray("eu-west", "us-east")) .WithOverride("Note", null);var result = await graph.ResumeAsync("scale-checkout", approval, store);Printed from the decision in Apply:
approved by alice@example.com at Propose DryRun = false Note = null Plan = {"BatchSize":2,"Steps":["drain","scale","verify"]} Regions = ["eu-west","us-east"] Replicas = 6let edited_plan = RolloutPlan { batch_size: 2, steps: vec!["drain".into(), "scale".into(), "verify".into()],};let approval = HumanSignal::approve("alice@example.com") .with_override("replicas", 6) .with_override("dry_run", false) .with_override("plan", serde_json::to_value(&edited_plan)?) .with_override("regions", vec!["eu-west", "us-east"]) .with_override("note", serde_json::Value::Null);let result = graph.resume("scale-checkout", approval, store.clone()).await;Printed from the decision in apply:
approved by alice@example.com at Propose dry_run = false note = null plan = {"batch_size":2,"steps":["drain","scale","verify"]} regions = ["eu-west","us-east"] replicas = 6| You pass | JSON written |
|---|---|
A number, such as 6, or a C# decimal |
A JSON number |
false or true |
A JSON boolean |
A string, such as "eu-west" |
A JSON string |
An object: JsonSerializer.SerializeToNode(plan) / serde_json::to_value(&plan)? |
A JSON object |
A list: new JsonArray(...) or SerializeToNode(list) / a Vec |
A JSON array |
null / serde_json::Value::Null |
null. The field is set to null, not removed. |
An override replaces a whole top-level field. Overriding Plan replaces the plan, so to change one step, send the whole plan with that step changed. Strata can’t patch inside a field.
Strata applies the overrides to the saved state before any node runs, so Apply already sees 6 replicas and the three-step plan.
Use the field’s JSON name
Section titled “Use the field’s JSON name”A key is the field’s name in the state’s JSON: Replicas for a plain C# record, replicas for a Rust struct. If C# and Rust services share a thread, pin the C# names to snake_case and use those keys in both. See Sharing a thread across languages.
A key your state doesn’t have is added to the JSON, and by default System.Text.Json and serde ignore it. So a misspelled key such as Replica silently does nothing. To have the resume refuse it, mark the state type [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] in C# or #[serde(deny_unknown_fields)] in Rust. The type then refuses unknown fields in every checkpoint it reads, so remove fields with care.
String overrides
Section titled “String overrides”FieldOverrides / field_overrides (Rust: with_field_override) is the older form. It writes every value as a JSON string, so "6" doesn’t fit an int or a u32. Use typed overrides for new code. One signal may use both maps, but not for the same key.
See what a resume refuses
Section titled “See what a resume refuses”Before Strata touches the thread, it applies the overrides to a copy of the saved state and turns that copy into your state type. If that fails, or a key is in both maps, the resume returns Failed, writes nothing and runs no node. Here three bad answers go to scale-search:
var wrongType = HumanSignal.Approve("alice@example.com").WithOverride("Replicas", "six");var asString = new HumanSignal(true, "alice@example.com", new Dictionary<string, string> { ["Replicas"] = "6" });var inBothMaps = new HumanSignal(true, "alice@example.com", new Dictionary<string, string> { ["Note"] = "a" }) .WithOverride("Note", "b");
foreach (var signal in new[] { wrongType, asString, inBothMaps }){ if (await graph.ResumeAsync("scale-search", signal, store) is GraphExecutionResult<ScaleChange>.Failed refused) { Console.WriteLine($"Refused: {refused.Message}"); }}
var waiting = await store.LoadAsync("scale-search");Console.WriteLine($"Still {waiting!.Status}, resumed at: {waiting.ResumedAt ?? "-"}");Refused: failed to rehydrate state for thread 'scale-search': The JSON value could not be converted to ScaleChange. Path: $.Replicas | LineNumber: 0 | BytePositionInLine: 36.Refused: failed to rehydrate state for thread 'scale-search': The JSON value could not be converted to ScaleChange. Path: $.Replicas | LineNumber: 0 | BytePositionInLine: 34.Refused: cannot resume thread 'scale-search': override 'Note' is set as both a string and a typed valueStill Interrupted, resumed at: -use datalium_strata::StrataError;use datalium_strata::store::CheckpointStore;
let wrong_type = HumanSignal::approve("alice@example.com").with_override("replicas", "six");let as_string = HumanSignal::approve("alice@example.com").with_field_override("replicas", "6");let in_both_maps = HumanSignal::approve("alice@example.com") .with_field_override("note", "a") .with_override("note", "b");
for signal in [wrong_type, as_string, in_both_maps] { if let GraphExecutionResult::Failed(StrataError::ExecutionAborted(message)) = graph.resume("scale-search", signal, store.clone()).await { println!("Refused: {message}"); }}
let waiting = store.load("scale-search").await?.expect("the thread has a checkpoint");println!("Still {:?}, resumed at: {:?}", waiting.status, waiting.resumed_at);Refused: failed to rehydrate state for thread 'scale-search': invalid type: string "six", expected u32Refused: failed to rehydrate state for thread 'scale-search': invalid type: string "6", expected u32Refused: cannot resume thread 'scale-search': override 'note' is set as both a string and a typed valueStill Interrupted, resumed at: NoneThe thread still waits, so show the message to the reviewer and let them answer again. What a resume refuses lists the other cases.
Check business rules after the boundary
Section titled “Check business rules after the boundary”The state type checks the shape of an answer, not that 50 replicas is too many. Strata has no hook that validates a resumed state (see Known limitations), so check it in the first node after the boundary, as Apply does.
Apply is a fallible node, so it reports a bad answer as a fatal failure. Say the reviewer approves scale-search with 50 replicas:
Apply failed: replicas must be between 1 and 20, got 50The resume returns NodeFailed in C#, Failed(StrataError::NodeFailed { .. }) in Rust. Nothing was applied, and the thread’s failed checkpoint still carries the approval. An operator can resume the failed thread with a correction, which re-runs Apply:
var correction = HumanSignal.Approve("bob@example.com").WithOverride("Replicas", 12);if (await graph.ResumeAsync("scale-search", correction, store) is GraphExecutionResult<ScaleChange>.Completed done){ Console.WriteLine($"{done.State.Status}, replicas {done.State.Replicas}");}let correction = HumanSignal::approve("bob@example.com").with_override("replicas", 12);if let GraphExecutionResult::Completed(done) = graph.resume("scale-search", correction, store.clone()).await{ println!("{}, replicas {}", done.status, done.replicas);}applied, approved by alice@example.com, replicas 12A resume of a failed thread applies its overrides but records no decision, so Apply still sees Alice’s approval, not Bob’s retry. If you’d rather not fail the thread, return the state with a status that says why. Either way, the check belongs in the node, not only in your form.
Read the decision
Section titled “Read the decision”A resume that answers a boundary, with an approval or a rejection, records a HumanDecision:
| C# | Rust | What it holds |
|---|---|---|
Boundary |
boundary |
The boundary it answered. |
IsApproved |
is_approved |
The answer. |
Reviewer |
reviewer |
The signal’s ReviewerToken / reviewer_token. |
Overrides |
overrides |
Every override applied, by key, as JSON. String overrides appear as JSON strings. |
DecidedAt |
decided_at |
When the resume began, as an RFC 3339 string. |
In a node
Section titled “In a node”Every node of the graph level reads it with ctx.Decision / ctx.decision(), until another boundary of that level is answered. It is null / None if the level was never resumed at a boundary. A subgraph doesn’t inherit its parent’s decision.
Read current values from the state. The decision tells you what the reviewer sent, for an audit log for example. To read an override back as your own type, deserialize it:
// In Apply. decision.Overrides is an IReadOnlyDictionary<string, JsonElement>.if (decision.Overrides.TryGetValue("Plan", out var planJson)){ var plan = planJson.Deserialize<RolloutPlan>()!; Console.WriteLine($"the reviewer changed the plan: batches of {plan.BatchSize}");}// In apply. decision.overrides is a serde_json::Map<String, Value>.if let Some(plan) = decision.overrides.get("plan") { let plan: RolloutPlan = serde_json::from_value(plan.clone()).map_err(|e| NodeError::Fatal(e.to_string()))?; println!("the reviewer changed the plan: batches of {}", plan.batch_size);}The decision keeps what the approver sent. After Bob’s correction above, Apply still finds Replicas = 50 in decision.Overrides, while its state holds 12.
From the checkpoint
Section titled “From the checkpoint”Every checkpoint the level writes while a decision is in effect carries it: a failed, cancelled or later interrupted checkpoint. Read it from Decision / decision on the loaded checkpoint:
var checkpoint = await store.LoadAsync("scale-search");if (checkpoint?.Decision is { } recorded){ Console.WriteLine($"{checkpoint.Status}: approved={recorded.IsApproved} by {recorded.Reviewer} at {recorded.Boundary}, {recorded.DecidedAt}");}let checkpoint = store.load("scale-search").await?.expect("a failed checkpoint");if let Some(recorded) = &checkpoint.decision { println!( "{:?}: approved={} by {} at {}, {}", checkpoint.status, recorded.is_approved, recorded.reviewer, recorded.boundary, recorded.decided_at );}Failed: approved=True by alice@example.com at Propose, 2026-10-30T14:49:26ZRust prints approved=true. Under the default checkpoint policy, a run that completes after the answer writes no further checkpoint, so if you need a record of every answer, write it from the node. See When checkpoints are written.
From a terminal, strata thread inspect prints a Decision line, and its --json output has a decision object:
strata thread inspect scale-search --storage checkpoints.dbThread: scale-search Status: failed Thread state: failed Current node: Apply Decision: approved by alice@example.com at Propose, 2026-10-30T14:45:00Z Next node: ApplyThe output is trimmed here.
Build a signal from a request
Section titled “Build a signal from a request”An approval usually arrives from a form or an HTTP API. Map the request to a signal yourself, field by field:
/// <summary>What the approval form posts. The reviewer is not in it.</summary>sealed record ApprovalRequest(bool Approve, int? Replicas, bool? DryRun, List<string>? Regions, string? Note);
static class Approvals{ public static HumanSignal ToSignal(ApprovalRequest request, string reviewer) { var signal = request.Approve ? HumanSignal.Approve(reviewer) : HumanSignal.Reject(reviewer); if (request.Replicas is { } replicas) signal = signal.WithOverride("Replicas", replicas); if (request.DryRun is { } dryRun) signal = signal.WithOverride("DryRun", dryRun); if (request.Regions is { } regions) signal = signal.WithOverride("Regions", JsonSerializer.SerializeToNode(regions)); if (request.Note is { } note) signal = signal.WithOverride("Note", note); return signal; }}
// reviewer comes from your authentication, never from the body.var request = JsonSerializer.Deserialize<ApprovalRequest>(body, JsonSerializerOptions.Web)!;var result = await graph.ResumeAsync("scale-search", Approvals.ToSignal(request, reviewer), store);/// What the approval form posts. The reviewer is not in it.#[derive(Debug, Deserialize)]struct ApprovalRequest { approve: bool, replicas: Option<u32>, dry_run: Option<bool>, regions: Option<Vec<String>>, note: Option<String>,}
fn to_signal(request: ApprovalRequest, reviewer: &str) -> HumanSignal { let mut signal = if request.approve { HumanSignal::approve(reviewer) } else { HumanSignal::reject(reviewer) }; if let Some(replicas) = request.replicas { signal = signal.with_override("replicas", replicas); } if let Some(dry_run) = request.dry_run { signal = signal.with_override("dry_run", dry_run); } if let Some(regions) = request.regions { signal = signal.with_override("regions", regions); } if let Some(note) = request.note { signal = signal.with_override("note", note); } signal}
// reviewer comes from your authentication, never from the body.let request: ApprovalRequest = serde_json::from_str(body)?;let result = graph.resume("scale-search", to_signal(request, reviewer), store.clone()).await;The body { "approve": true, "replicas": 50, "note": "peak traffic" } produced the failed scale-search run above. Keep these rules:
- List the fields a reviewer may change. Don’t copy the body’s keys into overrides, or a request could set
Statusor any other field. - Leave out what wasn’t sent. A missing field adds no override, so the saved value stays.
- Take the reviewer from the signed-in user, such as a user ID or an email address. Strata saves it in checkpoints and shows it to every node, so never put a token or other credential there.
- Decide who may approve yourself. Strata records the reviewer but doesn’t check that they may answer.
Overrides for an outer level
Section titled “Overrides for an outer level”When the boundary is inside a subgraph, the signal’s overrides go to the innermost state, the one the reviewer saw. To change an outer level’s state in the same resume, pass a LevelSignals with .At(path, signal) / .at(path, signal) for that level. Each level is checked against its own state type, and a refusal names the level, such as at level [Work]. An outer level’s signal applies its overrides and records no decision. Pause inside a subgraph has the code.
Why the signal isn’t generic
Section titled “Why the signal isn’t generic”There is no HumanSignal<T>. A signal passes through nested subgraphs, each with its own state type, and into checkpoints that the other engine and the CLI read. None of them knows your type. So the signal carries JSON, and your state type is what gets checked: build values with SerializeToNode / to_value, and read them back typed from the state.
Related
Section titled “Related”- Human-in-the-loop: the parts of a signal, and the recorded decision.
- Pause for approval: boundaries, reject routes, and finding paused threads.
- State: how state is stored, and JSON names across languages.
- Retries and backoff: fallible nodes and failed threads.
- CLI reference:
strata thread inspect. - The
05-hitl-interruptiondemo, in C# and Rust.