Skip to content

Approval Flow: Human-in-the-Loop Workflows

Approval Flow: Human-in-the-Loop Workflows

Section titled “Approval Flow: Human-in-the-Loop Workflows”

Your AI system generates a legal document, makes a financial decision, or prepares content for publication. Do you just let it act?

The dangerous approach: AI generates → auto-execute. Problems:

  • No human verification of AI judgment
  • Legal liability when AI makes mistakes
  • No audit trail showing who approved what
  • Impossible to catch errors before they cause harm

What you need: A workflow that:

  1. Lets AI do the heavy lifting (drafting, analysis)
  2. Pauses for human review at critical decision points
  3. Handles timeouts gracefully (humans forget)
  4. Supports rejection and re-work cycles
  5. Records who approved what, when, and why

This is the AwaitApproval pattern—human checkpoints in automated workflows.


After this example, you will understand:

  • Human approval gates that pause workflow execution
  • Timeout handling for unresponsive approvers
  • Escalation paths when approvals don’t arrive
  • Rejection cycles that route back for re-work
  • State persistence across hours or days of waiting

Where should humans be in the loop?

ApproachHuman RoleRisk LevelSpeed
Human-onlyDoes everythingLowestSlowest
AI-assistedReviews AI output before actionBalancedModerate
AI-monitoredNotified after AI actsHigherFast
AI-autonomousNo human involvementHighestFastest

Approval flows implement AI-assisted: AI does the work, humans make the final call.

Human approval isn’t bureaucracy—it’s risk management:

ConcernWhy Pause?
LiabilityWho’s responsible when AI makes mistakes? The approver.
Brand safetyAI might miss tone, context, or cultural sensitivities
ComplianceRegulations may require human review (financial, medical, legal)
JudgmentSome decisions require human values, not just optimization

Unlike instant operations, approvals can take hours or days:

10:00 AM - Workflow reaches approval step
10:01 AM - Notification sent to approver
[Workflow pauses, state persisted]
...
[Approver at lunch, in meetings, on vacation...]
...
3:15 PM - Approver reviews and approves
3:15 PM - Workflow resumes exactly where it paused

Key insight: The workflow state must survive process restarts, deployments, and server crashes while waiting for human input.

Humans are unreliable. They forget, go on vacation, or leave the company. Workflows need timeout strategies:

StrategyWhenOutcome
FailApproval is criticalWorkflow fails, manual intervention required
EscalateSomeone else can approveRoutes to backup approver
Auto-approveLow-risk decisionsProceeds automatically (with logging)
RemindApprover needs nudgingSends reminders before timeout

Approval isn’t always “yes.” Rejections route back for revision:

┌──────────────────────────────────────────────────────────────────┐
│ │
│ ┌─────────┐ ┌──────────┐ ┌────────────┐ ┌─────────┐ │
│ │ Draft │───▶│ Review │───▶│ Await │───▶│ Publish │ │
│ └─────────┘ └──────────┘ │ Approval │ └─────────┘ │
│ └────────────┘ │
│ │ │
│ [Rejected] │
│ │ │
│ ▼ │
│ ┌────────────┐ │
│ │ Address │ │
│ │ Concerns │──────────────────┘
│ └────────────┘
│ │
└────────────────────────────────────────┘ (loop until approved)

DecisionWhy This ApproachAlternativeTrade-off
Explicit approval stepClear checkpointImplicit approval via no-responseSlower, but explicit accountability
Timeout with escalationBusiness continuityFail on timeoutMay bypass original approver
Rejection loops backIterative improvementFail on rejectionMay loop indefinitely
State persists during waitReliabilityIn-memory waitSurvives restarts

Good fit when:

  • Actions have external consequences (publishing, payments, contracts)
  • Regulatory compliance requires human review
  • Decisions involve judgment, not just computation
  • Audit trails are required

Poor fit when:

  • Speed matters more than review (real-time systems)
  • Decisions are fully deterministic (no judgment needed)
  • No humans are available in the workflow
  • Actions are easily reversible
Anti-PatternProblemCorrect Approach
No timeoutWorkflows wait foreverAlways configure timeout with handling
Silent failureNo one knows workflow is stuckSend notifications, log state
Approval without contextApprover can’t make informed decisionInclude all relevant information
No rejection pathRejection = workflow deathRoute rejections to revision step
Mutable approval stateCan’t prove what was approvedImmutable approval records

┌───────────────┐ ┌─────────────┐ ┌───────────────────────────┐
│ DraftDocument │───▶│ LegalReview │───▶│ AwaitApproval │
│ │ │ │ │ <LegalTeam> │
│ AI generates │ │ AI analyzes │ │ │
│ draft │ │ legal issues│ │ [Timeout: 2 days] │
│ │ │ │ │ [OnTimeout: Escalate] │
│ │ │ │ │ [OnRejection: AddressFix] │
└───────────────┘ └─────────────┘ └───────────────────────────┘
┌──────────────────────────────┼──────────────────────────┐
│ │ │
▼ ▼ ▼
[Approved] [Rejected] [Timeout]
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ PublishDocument │ │ AddressConcerns │ │ EscalateToMgr │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
▼ ▼ │
┌─────────────────┐ (back to LegalReview) │
│NotifyStakeholder│ │
└─────────────────┘ │
┌──────────────────────────┘
(continues to Publish)
[WorkflowState]
public record DocumentState : IWorkflowState
{
// Identity
public Guid WorkflowId { get; init; }
// Input document
public Document Document { get; init; } = null!;
// Draft content (AI-generated)
public string? DraftContent { get; init; }
// Legal analysis (AI-generated)
public LegalReviewResult? LegalReview { get; init; }
// Human approval decision
public ApprovalDecision? Approval { get; init; }
// Workflow status flags
public bool IsPublished { get; init; }
public bool StakeholdersNotified { get; init; }
public bool IsEscalated { get; init; }
}

Why this design?

  • LegalReview: AI’s analysis, shown to human approver for context
  • Approval: Human decision, captures who/when/why
  • IsEscalated: Flag for timeout handling path
public record Document(
string Title,
string Author,
DocumentType Type,
string Content);
public record LegalReviewResult(
bool HasIssues, // Did AI find problems?
IReadOnlyList<string> Issues, // What problems?
string ReviewerComments); // AI's analysis
public record ApprovalDecision(
bool Approved, // Yes or no
string ApproverId, // WHO approved
DateTimeOffset DecisionTime, // WHEN they approved
string? Comments); // WHY (for rejections)
public enum DocumentType { Contract, Policy, Procedure, Marketing }
var workflow = Workflow<DocumentState>
.Create("document-approval")
.StartWith<DraftDocument>()
.Then<LegalReview>()
.AwaitApproval<LegalTeam>(options => options
.WithTimeout(TimeSpan.FromDays(2))
.OnTimeout(flow => flow.Then<EscalateToManager>())
.OnRejection(flow => flow
.Then<AddressLegalConcerns>()
.Then<LegalReview>()))
.Then<PublishDocument>()
.Finally<NotifyStakeholders>();

Reading this definition:

  1. Draft the document (AI)
  2. Legal review (AI analysis)
  3. Await approval from LegalTeam
    • If approved: continue to publish
    • If timeout (2 days): escalate to manager, then publish
    • If rejected: address concerns, re-review, request approval again
  4. Publish approved document
  5. Notify stakeholders

Basic approval (wait indefinitely):

.AwaitApproval<LegalTeam>()

With timeout (fail if no response):

.AwaitApproval<LegalTeam>(options => options
.WithTimeout(TimeSpan.FromDays(2)))

Timeout with escalation:

.AwaitApproval<LegalTeam>(options => options
.WithTimeout(TimeSpan.FromDays(2))
.OnTimeout(flow => flow.Then<EscalateToManager>()))

With rejection handling:

.AwaitApproval<LegalTeam>(options => options
.OnRejection(flow => flow
.Then<AddressLegalConcerns>()
.Then<LegalReview>()))

Multiple approvers:

.AwaitApproval<LegalTeam>(options => options
.RequireAll() // All team members must approve
.WithQuorum(2)) // OR: at least 2 must approve
public class LegalTeam : IApprover<DocumentState>
{
public string Role => "legal-team";
// Create the approval request with all context needed for decision
public ApprovalRequest CreateRequest(DocumentState state)
{
return new ApprovalRequest
{
Title = $"Legal Approval Required: {state.Document.Title}",
Description = "Please review the document and legal analysis.",
Context = new Dictionary<string, object>
{
["DocumentTitle"] = state.Document.Title,
["DocumentType"] = state.Document.Type.ToString(),
["LegalIssues"] = state.LegalReview?.Issues ?? [],
["ReviewerComments"] = state.LegalReview?.ReviewerComments ?? ""
}
};
}
// Apply the decision to state
public DocumentState ApplyApproval(DocumentState state, ApprovalDecision decision)
{
return state.With(s => s.Approval, decision);
}
}

The key insight: The approval request includes ALL context needed for the human to make an informed decision. Don’t make them dig for information.

public class EscalateToManager : IWorkflowStep<DocumentState>
{
private readonly IEscalationService _escalation;
public EscalateToManager(IEscalationService escalation)
{
_escalation = escalation;
}
public async Task<StepResult<DocumentState>> ExecuteAsync(
DocumentState state,
StepContext context,
CancellationToken ct)
{
await _escalation.EscalateAsync(
$"Document approval timeout: {state.Document.Title}",
state.WorkflowId,
ct);
return state
.With(s => s.IsEscalated, true)
.AsResult();
}
}

Escalation records the fact that normal approval didn’t happen. This is important for audit.

public class AddressLegalConcerns : IWorkflowStep<DocumentState>
{
private readonly IDocumentReviser _reviser;
public AddressLegalConcerns(IDocumentReviser reviser)
{
_reviser = reviser;
}
public async Task<StepResult<DocumentState>> ExecuteAsync(
DocumentState state,
StepContext context,
CancellationToken ct)
{
// Use AI to address the rejection feedback
var revisedContent = await _reviser.AddressIssuesAsync(
state.DraftContent!,
state.LegalReview!.Issues,
state.Approval?.Comments, // Include rejection reason
ct);
return state
.With(s => s.DraftContent, revisedContent)
.With(s => s.LegalReview, null) // Clear for re-review
.With(s => s.Approval, null) // Clear previous decision
.AsResult();
}
}

After addressing concerns, the workflow loops back to LegalReview, then requests approval again.


Approvals come from an external system (UI, API, email, etc.):

public class ApprovalController : ControllerBase
{
private readonly IApprovalService _approvals;
[HttpPost("{workflowId}/approve")]
public async Task<IActionResult> Approve(
Guid workflowId,
[FromBody] ApprovalRequest request)
{
await _approvals.SubmitDecisionAsync(workflowId, new ApprovalDecision(
Approved: true,
ApproverId: User.Identity!.Name!,
DecisionTime: DateTimeOffset.UtcNow,
Comments: request.Comments));
return Ok();
}
[HttpPost("{workflowId}/reject")]
public async Task<IActionResult> Reject(
Guid workflowId,
[FromBody] RejectionRequest request)
{
await _approvals.SubmitDecisionAsync(workflowId, new ApprovalDecision(
Approved: false,
ApproverId: User.Identity!.Name!,
DecisionTime: DateTimeOffset.UtcNow,
Comments: request.Reason)); // Reason is required for rejections
return Ok();
}
}

When the decision arrives, the workflow resumes exactly where it paused.


Trust in AI systems comes from transparency and control, not from the AI being perfect.

The approval step isn’t a bottleneck—it’s the difference between “AI error” and “approved decision with known risk.” When something goes wrong at 2 AM, you’ll be glad you can answer “Who approved this?” with a name, timestamp, and their reasoning.

The workflow persists its state across hours or days of waiting. Process restarts, deployments, even server crashes don’t lose the approval context. When the human finally responds, the workflow picks up exactly where it left off.


Build dashboards showing what needs attention:

// Find all documents awaiting legal approval
var pending = await session
.Query<DocumentApprovalReadModel>()
.Where(d => d.CurrentPhase == DocumentApprovalPhase.AwaitingApproval)
.ToListAsync();
// Find approvals approaching timeout
var urgent = await session
.Query<DocumentApprovalReadModel>()
.Where(d => d.CurrentPhase == DocumentApprovalPhase.AwaitingApproval
&& d.ApprovalRequestedAt < DateTimeOffset.UtcNow.AddHours(-36))
.ToListAsync();

Send reminders before timeout:

  1. Configure reminder interval (e.g., every 8 hours)
  2. Create SendReminder step
  3. Add to timeout path: remind → wait → remind → escalate

Require multiple approvals in sequence:

  1. Add AwaitApproval<LegalTeam>() followed by AwaitApproval<Finance>()
  2. Each approval captures different perspective
  3. Handle rejection at any level

Exercise 3: Conditional Approval Requirements

Section titled “Exercise 3: Conditional Approval Requirements”

Different documents need different approval levels:

  1. Add logic to check Document.Type
  2. Marketing → single approver
  3. Contract → legal + executive approval
  4. High-value → board approval

  1. Approval steps persist workflow state and wait for external input
  2. Timeouts prevent workflows from waiting forever—always configure handling
  3. Escalation paths maintain business continuity when approvers don’t respond
  4. Rejection paths enable iterative improvement—not workflow death
  5. Context is critical—approvers need all information to make good decisions
  6. Audit trails capture who approved what, when, and why