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.

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.

  • Install Strata as shown in Installation. In .NET, add Datalium.Strata.Hosting (or Datalium.Strata and Datalium.Strata.Storage). In Rust, add datalium-strata, datalium-strata-memory, serde and serde_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);

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.

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

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.

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.

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 value
Still Interrupted, resumed at: -

The thread still waits, so show the message to the reviewer and let them answer again. What a resume refuses lists the other cases.

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 50

The 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}");
}
applied, approved by alice@example.com, replicas 12

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

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.

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

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.

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}");
}
Failed: approved=True by alice@example.com at Propose, 2026-10-30T14:49:26Z

Rust 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:

Terminal window
strata thread inspect scale-search --storage checkpoints.db
Thread: 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: Apply

The output is trimmed here.

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

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 Status or 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.

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.

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.