A reviewer opens the proposal for case T-1042: the selected source versions, the accepted findings, the exact reply and the policy that governed the work. The reply explains the known firmware issue and asks for a diagnostic export without recommending a rollback. The reviewer approves. The process writes the support review record, then crashes before it can return a receipt. The screen shows an error.
What should happen when the reviewer, or a retry loop, approves again? If the answer is "it depends on timing", the system will eventually create two records for one decision. This last part of the series makes the answer deterministic: one reviewed proposal, one operation identity, one local effect, recovered across a process restart with Microsoft Agent Framework checkpoints.
- A checkpoint records where a workflow is. It does not record whether the business effect happened, and the same checkpoint can be resumed any number of times.
- Bind approval to an operation identity computed from canonical bytes: case, action, exact draft, selected-evidence identity and effective policy. Any change means a new proposal.
- Key the effect by that identity and write it once. A retry after a lost receipt then finds the existing record and reports a replay instead of creating a second one.
- Reconcile by reading the effect store, not by rerunning the workflow or asking a model whether it probably succeeded.
Three identities answer three different questions
Recovery designs go wrong when one identifier is asked to do three jobs. In this application there are three, and each is insufficient on its own:
| Identity | What it names | What it cannot tell you |
|---|---|---|
| Session and checkpoint | A position in one framework execution, such as "waiting at the review port" | Whether the business effect occurred |
| Operation identity | The exact case, action, draft, evidence and policy being reviewed | Who approved it, or whether it was executed |
| Operation-keyed effect record | The local business result for that operation | Whether some other, remote system committed |
A checkpoint can be replayed. A proposal can be reconstructed. Neither fact authorizes a second effect. Conversely, an effect can exist while the caller has no receipt and the workflow never recorded a successful return from its commit step. The lost receipt in the opening is exactly that state.
The effect in this application is deliberately local: a review record named record_support_review. No email, refund, firmware change or external ticket update happens. Keeping the effect local is what lets the recovery argument be complete; the last section explains what changes when the effect lives in someone else's system.
Specify canonical bytes, not "the same JSON"
The operation identity binds, in this order: a protocol tag decisionflow.operation.v1, the case ID, the action name, the canonical draft body, the selected-evidence identity from Part 8 and the effective policy identity from Part 9. How those fields become bytes is part of the contract.
Hashing display JSON makes the identity depend on indentation and property order. Concatenating strings without framing is worse: the fields ab, c and a, bc concatenate to the same text. The encoding here normalizes CRLF and lone CR to LF, leaves every other character alone, encodes each field as UTF-8 and prefixes it with its byte length as a four-byte big-endian integer. SHA-256 over that byte sequence, with the prefix op-v1-, is the identity.
public static string Text(string s) => s.Replace("\r\n", "\n").Replace('\r', '\n');
public static byte[] Encode(IEnumerable<string> fields)
{
using var stream = new MemoryStream();
Span<byte> length = stackalloc byte[4];
foreach (string field in fields)
{
byte[] bytes = Encoding.UTF8.GetBytes(Text(field));
BinaryPrimitives.WriteInt32BigEndian(length, bytes.Length);
stream.Write(length);
stream.Write(bytes);
}
return stream.ToArray();
}
public static string Id(string caseId, string action, string body, string evidenceId, string policyId) =>
"op-v1-" + Convert.ToHexString(SHA256.HashData(Encode(
["decisionflow.operation.v1", caseId, action, body, evidenceId, policyId]))).ToLowerInvariant();
Lengths count bytes, not .NET char values or JavaScript string units, which is why the test vectors include Polish text. Encoded by the C# code above and, independently, by a short Python script using str.encode('utf-8'), struct.pack('>I', ...) and hashlib.sha256, the vectors agree byte for byte:
ab|c 0000000261620000000163
a|bc 0000000161000000026263
Proszę zebrać eksport diagnostyczny. 00000026 50726f737ac499 ... 6e792e (36 chars, 38 bytes)
line one\r\nline two 000000116c696e65206f6e650a6c696e652074776f
line one\nline two 000000116c696e65206f6e650a6c696e652074776f
The first two rows show why framing matters: same concatenation, different bytes. The Polish row is 36 characters and 38 bytes, because "ę" and "ć" take two bytes each. The last two rows show the one normalization that is applied. No trimming, case folding or Unicode normalization happens, so adding a sentence, changing a space or changing a source version changes the operation. A line-ending-only change does not, and the reviewer sees the canonical LF body that is stored.
Change the draft, the evidence or the budget, and the approval no longer applies
- The recorded proposal: case and action, the two licensed clauses from Part 8, the selected-evidence identity and the policy with a 9-unit allowance. Its operation identity starts e18cbda4.
- A reviewer adds one sentence. The identity becomes 5f8d04a4. An approval prepared against e18cbda4 cannot be applied to text the reviewer never saw.
- HOLD-7 moves from version 2 to 4 with an identical quotation. The reply does not change, but the evidence identity does, and so does the operation: c114c604.
- Raising the allowance from 9 to 10 units changes the policy identity and the operation (452acb47), even if the policy keeps its human-readable version label.
- The same draft with line breaks yields ecffdcee whether it was saved with LF or CRLF. That is the only variation the protocol deliberately ignores.
Identities computed by the C# encoder in this article (first 12 hex digits shown) and cross-checked in Python for the recorded proposal.
A digest here is an identifier and a consistency check. It is not a signature, an authorization grant or a substitute for protecting the state directory. Someone who can replace the program and all of its records is outside what this design defends against.
A human edit is a new proposal
Editing is where approval systems quietly lose their meaning. If the reviewer can change the reply and then approve, which text did they approve, and which checks still apply? The rules that keep this honest:
- Saving an edit carries the
expectedOperationIdof the proposal on screen. If the current proposal has a different identity, the save is refused as stale. - An accepted edit creates a new proposal with a new identity, explicitly marked as human-edited. The licensed-clause checks of Part 8 cover only the clauses they checked; they do not transfer to sentences a person wrote.
- Changing only a source version also creates a new proposal. That is the hard case worth testing: the visible text is identical, and an approval from an older browser tab must still be refused.
- A decision, once recorded, is immutable. Repeating the same decision is a replay. Changing approve to decline under the same identity is a conflict, not an update.
Approve whatever is current
app.MapPost("/cases/{id}/approve", async (string id, Store store) =>
{
var proposal = await store.CurrentProposalAsync(id); // whatever is newest
return await store.CommitAsync(proposal); // no binding, no key
});
A click on a stale tab approves a draft the reviewer never saw, and a retry commits twice.
Approve exactly one identity
app.MapPost("/cases/{id}/approve", async (string id, ApproveRequest r, Store store) =>
{
if (r.Approved is not bool approved) return Results.BadRequest(); // no implicit consent
var current = await store.CurrentProposalAsync(id);
if (current.OperationId != r.OperationId) return Results.Conflict("stale proposal");
return Results.Ok(store.Commit(new ReviewDecision(r.OperationId, approved, r.Reviewer)));
});
The request names the operation it approves; an omitted boolean is not consent; the commit is keyed by that identity.
Reviewer identity matters too, but it is a separate concern. In production the reviewer comes from the application's authentication and approval policy, attached at this endpoint, while the operation binding below stays the same. Giving that reviewer a decision they can still change, rather than a rubber stamp, is the subject of the human in the loop needs a job description.
The workflow, its pending request and the checkpoint
The workflow has three executors with fixed IDs: prepare builds the immutable proposal and its identity, human is a typed request port, and commit owns the local effect. In Microsoft Agent Framework, RequestPort.Create with the request type ReviewProposal, the response type ReviewDecision and the ID human-review-v1 declares a typed external request: when a message reaches the port, the run pauses with status PendingRequests and emits a RequestInfoEvent. The framework provides the request and response mechanism; what approval means, and whether a response is valid, is the application's job.
With a CheckpointManager, the framework writes a checkpoint at the end of every superstep, capturing executor state, pending messages and pending requests. The begin command runs until the review request, records the session ID, checkpoint ID and operation identity in a small pending.json, disposes the run and the FileSystemJsonCheckpointStore, and exits. A later process resumes from that checkpoint:
using var cpStore = new FileSystemJsonCheckpointStore(new DirectoryInfo(Path.Combine(dir, "checkpoints")));
var manager = CheckpointManager.CreateJson(cpStore);
await using Run run = await InProcessExecution.ResumeAsync(Build(store, lose),
new CheckpointInfo(pending.SessionId, pending.CheckpointId), manager);
WorkflowEvent[] events = run.NewEvents.ToArray(); // read once
var req = events.OfType<RequestInfoEvent>().SingleOrDefault();
req.Request.TryGetDataAs(out ReviewProposal? p);
if (p!.OperationId != pending.OperationId) return 4; // not the proposal we recorded
await run.ResumeAsync([req.Request.CreateResponse(
new ReviewDecision(p.OperationId, approved, reviewer))]);
WorkflowEvent[] after = run.NewEvents.ToArray();
var failed = after.OfType<ExecutorFailedEvent>().FirstOrDefault(); // never treat silence as success
Three framework details showed up when running this, and each one shapes the design:
- The pending request is re-emitted on resume. After
ResumeAsyncthe run is again inPendingRequestswith the same proposal. That is convenient, and it means the same checkpoint can be resumed and answered any number of times. Nothing in the checkpoint prevents a second commit. Run.NewEventsmoves a bookmark. It returns events since the last access. In a one-executor test the first read returned 6 events and an immediate second read returned 0. Read it once into an array, then look for the request, the output and failures in that array.- The rebuilt workflow must match the checkpoint. After changing the
commitexecutor's output type, resuming an older checkpoint failed withInvalidDataException: "The specified checkpoint is not compatible with the workflow associated with this runner." The documentation states the rule: a rehydrated workflow must keep the topology and executor identities, and agents inside it need a stableChatClientAgentOptions.Id. Record a workflow version with every pending request and refuse to resume across an incompatible deploy.
The file store's own documentation describes it as durable, process-exclusive persistence whose instances are not thread-safe. That is a single-host design: take a local writer lock around begin, resume and reconcile, and do not point several production instances at one directory. Treat the checkpoint directory as trusted storage, because the documentation is explicit that checkpoints must never be loaded from untrusted or tampered sources.
Put the effect behind its own immutable key
The checkpoint cannot answer "did the effect happen?". The effect store must, so the effect is keyed by the operation identity and written once.
Three executors, and a commit that cannot create a second record
static Workflow Build(EffectStore store, bool loseReceipt)
{
var prepare = new Func<CaseInput, ReviewProposal>(c =>
new ReviewProposal(Operation.Id(c.CaseId, "record_support_review", c.Body, EvA, P(9)), c.CaseId, c.Body))
.BindAsExecutor("prepare");
var human = RequestPort.Create<ReviewProposal, ReviewDecision>("human-review-v1");
var commit = new Func<ReviewDecision, CommitResult>(d => store.Commit(d, loseReceipt))
.BindAsExecutor("commit");
return new WorkflowBuilder(prepare)
.AddEdge(prepare, human)
.AddEdge(human, commit)
.WithOutputFrom(commit)
.Build();
}
bool WriteOnce(string path, object value)
{
string tmp = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
using (var fs = new FileStream(tmp, FileMode.CreateNew, FileAccess.Write))
{
JsonSerializer.Serialize(fs, value);
fs.Flush(flushToDisk: true);
}
File.Move(tmp, path, overwrite: false);
return true;
}
catch (IOException) when (File.Exists(path)) { return false; }
finally { if (File.Exists(tmp)) File.Delete(tmp); }
}
public CommitResult Commit(ReviewDecision d, bool loseReceipt)
{
string decisionPath = PathFor("decision", d.OperationId);
if (!WriteOnce(decisionPath, d))
{
var existing = JsonSerializer.Deserialize<ReviewDecision>(File.ReadAllText(decisionPath))!;
if (existing.Approved != d.Approved) return new(d.OperationId, "ConflictingDecision", false, EffectCount());
}
if (!d.Approved) return new(d.OperationId, "Declined", false, EffectCount());
bool created = WriteOnce(PathFor("effect", d.OperationId), d);
if (loseReceipt) throw new LostReceiptException(); // teaching fault: after the effect
return new(d.OperationId, "Committed", Replay: !created, EffectCount());
}
- The prepare executor computes the operation identity once, from the canonical fields. Everything downstream carries that value instead of recomputing it from mutable state.
- The human step is a typed request port with a stable ID. The run pauses here, and the checkpoint records the pending request.
- Commit is the only executor with a side effect. Stable executor IDs are what let a new process rebuild this graph and resume the old checkpoint.
- Write once: serialize to a fresh temporary file, flush it to disk, then move it into place without overwriting. If the target already exists, report that instead of replacing it.
- The decision is immutable. The same decision again is a replay; a different decision for the same identity is a conflict. A decline creates no effect.
- The effect file is named by the operation identity. The deliberate fault fires after it exists and before a receipt is returned, which is the worst moment to lose a reply.
Flushing and renaming is a reasonable local protocol, not a proof against every failure. It was exercised here against process exit and restart, lost responses and repeated approvals. It was not tested against power loss, and it does not make the file system transactional. If the decision is written and the process dies before the effect, reconciliation reports NoLocalEffectRecorded; resuming the approved operation then creates the one record.
Run the interruption across separate processes
Separate process invocations make lifetimes unambiguous: nothing survives in memory between commands. The sequence below is what the program did on .NET 10 with Microsoft.Agents.AI.Workflows 1.28.0.
The effect is written, the receipt is lost, and the retry finds the record
- The operator starts the case. The host prepares the proposal with identity e18cbda4 (in the full pipeline, after retrieval and coordination) and reaches the review port.
- The run pauses with
PendingRequests, the checkpoint is on disk, and the first process exits. Nothing about the case remains in memory. - A new process resumes the checkpoint. The review request is re-emitted, and the host checks that it carries the recorded operation identity.
- The approval reaches the commit executor, which writes the decision and the effect file named by the operation identity.
- The teaching fault throws after the effect exists. The host sees
ExecutorFailedEventand exits with code 42: no receipt, and no claim of success. - Reconciliation reads the effect store instead of rerunning anything. It reports
Committedwith one effect. - The operator approves again. The same checkpoint is resumed and answered a second time, and the write-once move finds the effect file already present.
- The commit reports
Replaywith an effect count of one. The record was created once, the decision was made once.
The console output, abridged to the lines that matter:
begin: status=PendingRequests op=op-v1-e18cbda41ce2f1bd... checkpoints=2
approve: resumed status=PendingRequests requestReemitted=True
executor failed: LostReceiptException (exit 42)
reconcile: state=Committed effects=1
approve: resumed status=PendingRequests requestReemitted=True
result={"Status":"Committed","Replay":true,"EffectCount":1}
approve: result={"Status":"Committed","Replay":true,"EffectCount":1}
decline: result={"Status":"Declined","Replay":false,"EffectCount":0}
approve: result={"Status":"ConflictingDecision","Replay":false,"EffectCount":0}
naive key: result={"Status":"Committed","Replay":false,"EffectCount":2}
The last line is the control experiment. With the effect file named by a fresh GUID per attempt instead of the operation identity, the same lost-receipt sequence produced two effects. Everything else was identical: same workflow, same checkpoint, same approvals. The idempotency lives entirely in the key.
Pitfalls
Checkpoint and resume give you "continue from here", not "do this once". Because a pending request can be resumed and answered repeatedly, every executor with a side effect needs its own idempotency key and a store that can say whether the effect already exists.
- Identity from display JSON. Serializer settings change; framed canonical bytes do not.
- Approving the current proposal. Approval must name the identity it approves. A stale tab must fail loudly.
- Interpreting a missing receipt as failure. The effect may exist. Reconcile before retrying, and report uncertain state as uncertain.
- Reading
NewEventstwice. The second read is empty by design. Copy it once. - Deploying a changed graph under pending checkpoints. Executor IDs and topology must match. Version the workflow and drain or migrate pending cases.
- Sharing a file checkpoint store across instances. It is process-exclusive by design. Use a store built for concurrent access if you need several hosts.
When the effect lives in someone else's system
Replace the local review record with a call to a remote ticketing API and the proof changes. A local file cannot tell you whether the remote side committed before your request timed out. The receiver needs to accept the operation identity as an idempotency key, or offer a lookup by that key, or some other protocol suited to its transaction boundary. A negative answer from an eventually consistent lookup may not settle the question, so keep the state "unknown" visible until evidence resolves it. The same operation identity must survive every retry. The receiver-side half of this problem is worked through in after the receiver commits: recover a lost acknowledgement.
What to rely on for which guarantee
| Guarantee you need | Mechanism | Not enough on its own |
|---|---|---|
| Continue a paused workflow after a restart | Checkpoints with stable executor IDs and a recorded workflow version | Checkpoints say nothing about effects |
| Approval applies to exactly what was reviewed | Operation identity over canonical bytes, sent with the approval | A version label, or the draft text alone |
| At most one local effect per approved operation | Effect keyed by operation identity, written once | A retry flag, or a check-then-write without an atomic step |
| Know the outcome after a lost receipt | Reconciliation that reads the effect store | Rerunning the workflow, or asking a model |
| At most one remote effect | Receiver-side idempotency key or lookup by operation identity | Anything stored only on the caller's side |
The whole chain, end to end
The series ends with one inspectable chain. A typed question selects a contract. Eligible passages are selected before ranking and must cover every required fact. Specialists are called only where the graph allows, inside a budget, and their findings count only with licensed citations. The proposal binds the exact text, evidence and policy into one identity. A person approves that identity at a pending request that survives a restart, and the effect is keyed so that a lost receipt costs a reconciliation, not a duplicate. Each stage keeps what the next stage needs to check, which is what turns a model's judgment into part of an application with owners.
Checklist
- The operation identity is SHA-256 over length-prefixed UTF-8 fields with documented normalization, checked against vectors from a second implementation.
- Approvals carry the operation identity; stale or omitted approvals are refused.
- Human edits and source-version changes create new proposals with new identities.
- Decisions are immutable; a changed decision for the same identity is a conflict.
- Every side effect is keyed by the operation identity and written once.
- Reconciliation reads the effect store and distinguishes committed, declined, approved without effect, and pending.
- Pending checkpoints record a workflow version, executor IDs are stable, and
NewEventsis read once per step. - The checkpoint directory is private, single-writer storage.
Series: Decision APIs in .NET, part 10 of 10. Previous: Part 9, when an agent coordinator has a real choice. Back to the Decision APIs series overview.
Further reading
- Workflow checkpoints (Microsoft Learn): when checkpoints are created, what they capture, rehydration rules and the trust boundary.
- Human in the loop (Microsoft Learn): request ports,
RequestInfoEventand responses. - Microsoft.Agents.AI.Workflows source:
RequestPort,RunandFileSystemJsonCheckpointStore. - File.Move (.NET API reference): the
overwriteparameter and the exception when the destination exists. - RFC 6234, US Secure Hash Algorithms: SHA-256.




