TUTORIAL21 min read

Human Approval in an AI Harness: Pause, Review and Resume Safely

Build an approval gate that binds a reviewer’s decision to one exact action, expires safely, survives retries and verifies the result after execution.

P
The PADISO TeamPublished 24 Aug 2026

A human approval is not a durable safety control if the agent can change the action after someone clicks “Approve.” The review must apply to a specific proposed action, for a limited period, and only once. Then the system must check what actually happened after execution instead of treating a successful tool call as proof of a successful business outcome.

This tutorial builds that lifecycle for a hypothetical purchase-order change. The agent proposes changing a supplier order from 40 to 60 units. A reviewer may approve, reject or request revision. If the order changes while the request is waiting, or the approval expires, the old decision cannot authorize the new action. The design is deliberately independent of a particular agent framework or database; adapt the storage and orchestration pieces to the stack you operate.

The essential sequence is: create an immutable proposal, pause for review, validate the exact approved proposal at resume time, execute through an idempotent application boundary, and verify the resulting order state. Each step has its own failure outcome. A timeout is not an approval, a retry is not a new permission, and a model’s claim that it finished is not confirmation from the system of record.

What you are building

The approval gate sits between an agent’s intent and a consequential tool call. It does not ask the model to decide whether its own action is safe after the fact. Instead, application code determines which action needs review, records the proposed parameters, and prevents the execution path from proceeding until a valid decision is recorded.

A proposal should be a concrete command rather than a paragraph such as “update the supplier order.” For this example, its meaningful fields are the operation, order identifier, current order version, proposed quantity, supplier identifier, currency if money is involved, and any other parameter that changes the effect. The reviewer must see enough context to decide, while the execution service receives precisely the command that was reviewed.

The approval record and the action record are related but not interchangeable. The proposal describes what could happen. The decision describes who approved or rejected that proposal and when. The execution record says what the application attempted. The verification record says whether the intended state was observed afterward. Keeping these facts separate makes it possible to diagnose a timeout, a stale approval or a downstream failure without guessing which part of the workflow occurred.

LangGraph documents interrupts for pausing and resuming workflows. In the approval design below, the application enforces approval scope and expiry (interrupts documentation). That distinction is useful regardless of orchestration choice: a pause mechanism can preserve a workflow’s place, but your application still has to define what the human approved and whether that approval remains valid.

This walkthrough is about this approval lifecycle, not a full agent architecture. If you need the broader component model first, see The Anatomy of an AI Agent: What Each Component Does. For a deeper comparison of building orchestration yourself versus adopting a particular SDK, read Claude Agent SDK: When It Beats Your Own Orchestration.

Prerequisites and setup

Before implementing the flow, identify the application boundary that can make the consequential change. In this example, that is a purchase-order service with a way to read an order, update it conditionally against its current version, and retrieve it afterward. If the underlying system cannot expose a reliable current state or a conditional update, the approval workflow cannot safely assume that the order remained unchanged while a reviewer considered it.

You also need durable storage for proposals, decisions, and execution status. A relational database is one reasonable option, but the important requirement is transactional state changes for the application’s records. You need a worker or request handler that can resume an approved proposal after a human decision, plus a way to notify a reviewer and return them to a review screen. The specific notification channel is an implementation choice, not a safety property.

Define an application-controlled clock and a clear expiry policy before coding. The example below uses a hypothetical 30-minute approval window. That duration is illustrative, not a recommended standard. Choose a period that reflects how quickly the underlying action can become stale and how long review normally takes. Store timestamps in a consistent timezone, compare expiry using the server-side clock, and do not rely on a browser clock to authorize execution.

Finally, decide what happens when review does not complete. For a purchase-order modification, a sensible default is to leave the order unchanged, mark the proposal expired, and require a fresh proposal if the agent still recommends an update. That is a workflow decision for this example, not a universal rule for every business process. A safe default should make the absence of a decision visible rather than silently converting it into permission.

The diagram shows the decision points. The “recheck” step is important: even a valid, unexpired approval should not execute if the current order no longer matches the context shown to the reviewer.

flowchart TD
  accDescr: Workflow stages and decisions: Create proposal and payload hash, Pause for human review, Decision recorded, Approved and unexpired?, Reject, expire, or request revision, Recheck order, then execute once, Verify result and record outcome. The adjacent text explains the conditions and exceptions.
  accTitle: Human Approval in an AI Harness — Pause, Review and Resume Safely workflow
    A["Create proposal and payload hash"] --> B["Pause for human review"]
    B --> C["Decision recorded"]
    C --> D{"Approved and unexpired?"}
    D -->|"No"| E["Reject, expire, or request revision"]
    D -->|"Yes"| F["Recheck order, then execute once"]
    F --> G["Verify result and record outcome"]

The branch marked “No” is a terminal outcome for that proposal; it does not resume execution with a weaker check. The “Yes” branch still checks the order before making a change, because an approval does not freeze external state. If the order has changed, the system should stop and ask for a new decision rather than attempting to reinterpret the reviewer’s intent.

Step 1: Define the proposal and its state transitions

Start with a small, explicit state model. For the example, use pending, approved, rejected, expired, stale, executing, completed, and failed. These names are illustrative. The important property is that each transition has an owner and a guard. A pending proposal can receive one decision; an approved proposal can proceed only if its expiry and version checks pass; an executing proposal cannot be submitted as a second independent execution just because a request timed out.

Record the proposal as structured data. One possible schema is shown below. It is a design sketch, not a complete database migration; add the types, indexes, constraints, retention policy, and access controls needed for your environment.

ApprovalProposal
  id
  tenant_id
  action_type
  target_id
  expected_target_version
  payload_json
  payload_hash
  created_at
  expires_at
  status

ApprovalDecision
  proposal_id
  decision_id
  reviewer_id
  outcome              # approved, rejected, revision_requested
  decided_at
  comment

ExecutionAttempt
  proposal_id
  idempotency_key
  status               # started, succeeded, failed, unknown
  started_at
  finished_at
  external_reference
  result_summary

Verification
  proposal_id
  checked_at
  expected_state
  observed_state
  outcome              # verified, mismatch, unavailable

The target version represents the state the reviewer’s proposal was based on. It might be a version number, an ETag, or an application-specific revision field. Use a value that the system of record can compare reliably. If the business system has no version marker, define a carefully chosen comparison over relevant fields, but recognize that field comparisons can miss changes outside the selected set.

The payload should hold the exact operation and its parameters. Do not create a hash over a display string that can differ in formatting while representing the same operation; canonicalize the structured data first. Include all fields that can change the consequence. If an amount, recipient, account, quantity, destination or timing parameter can affect the result, it belongs in the bound payload or in another explicitly checked condition.

Keep mutable display details out of the authority check unless they affect the action. A supplier’s current company name can change without changing the supplier identifier, for example. Conversely, changing the supplier identifier must invalidate an approval even if the display name looks similar. This separation helps the review screen remain readable without confusing labels with executable parameters.

Step 2: Create an immutable proposal and pause

When the agent requests the purchase-order update, application code should validate the input, read the current order, and create a proposal from the validated values. Do not let the agent supply an arbitrary approval identifier or choose its own expiry. The application generates the proposal ID, records the current target version, assigns an expiry, canonicalizes the action payload, and calculates its hash.

For example, the proposal might contain action_type = update_quantity, target_id = PO-4821, expected_target_version = 17, and a payload specifying quantity = 60. The review page should show the current quantity of 40, the proposed quantity of 60, the relevant supplier and order context, and the expiry time. Those details make the change legible; the authorization check still uses the stored structured proposal, not text copied from the page.

Only after the proposal is committed should the workflow yield or pause. This ordering avoids a difficult failure in which the workflow pauses with a task identifier but no durable proposal to display. If the pause operation fails after storage succeeds, a recovery process can find a pending proposal and resume or notify the reviewer. If storage fails, the application should not display a review request that cannot later be resolved.

A pause mechanism needs a durable association between the suspended workflow and the proposal. Store whichever workflow reference your orchestration layer requires, but do not treat possession of that reference as approval. The resume path must load the proposal from trusted application storage and re-run the authorization checks. For a framework-specific prerequisite, see the guide to choosing an agent orchestration approach rather than assuming that a pause alone enforces your business policy.

Expected result: the reviewer can open one pending proposal, see the exact change and its expiry, and the agent has not yet made the change. If the proposal creation fails, there should be no executable approval task. If the workflow is paused but the reviewer interface cannot retrieve the record, treat that as an operational defect to repair, not as a reason to execute without review.

Step 3: Record a decision against the stored proposal

The review action should submit a proposal ID and a decision, not a replacement payload. The server loads the proposal, confirms that it is pending and not expired, and records the reviewer’s decision in a transaction or equivalent guarded operation. If the reviewer wants to change the quantity from 60 to 55, record “revision requested” and create a new proposal through the normal path. Do not edit the old proposal in place and preserve its approval status.

This is the key protection against approval drift. Suppose the interface initially displays a quantity of 60. A later code path changes the stored payload to 65 but leaves the status as approved. A check that reads only status == approved would authorize an action the reviewer never saw. Binding the decision to an immutable proposal prevents that mismatch. If an intentional revision is necessary, a new proposal gets a new identifier, hash, expiry, and review decision.

The server should reject duplicate or conflicting decisions. A repeated request with the same decision identifier can return the recorded outcome without creating a second decision. A different decision arriving after an approval has already been recorded should not silently overwrite it; return the current state and require a defined correction path. This protects against a double-click, a stale browser tab, or two reviewers acting at nearly the same time.

Capture enough context to reconstruct the decision later: proposal ID, reviewer identity as represented by your application, decision, timestamp, and any comment the workflow needs. Avoid asking the model to infer who approved or why from a chat transcript. The decision is an application event, and its recorded actor and time should come from the application’s trusted request context.

Expected result: exactly one effective decision is associated with the proposal. A rejection or revision request cannot be mistaken for approval, and an approval for a previous payload cannot be reused for a replacement payload. The review screen should show a clear result if another reviewer has already decided or if the request expired while the page was open.

Step 4: Resume only after validating scope and expiry

On resume, load the proposal and decision from durable storage. Do not trust values passed back by the model, a browser, or a callback to define what was approved. Check that the decision is approved, that it belongs to this proposal, that the proposal is in an eligible state, and that the application clock is before expires_at. Also verify that the canonical payload still matches its recorded hash.

A simplified guard might look like this:

from datetime import datetime, timezone

class ApprovalError(Exception):
    pass

def authorize_resume(proposal, decision, now=None):
    now = now or datetime.now(timezone.utc)

    if proposal.status != "approved":
        raise ApprovalError("proposal is not approved")
    if decision.proposal_id != proposal.id or decision.outcome != "approved":
        raise ApprovalError("decision does not authorize this proposal")
    if now >= proposal.expires_at:
        raise ApprovalError("approval has expired")
    if canonical_hash(proposal.payload_json) != proposal.payload_hash:
        raise ApprovalError("proposal payload changed")

    return proposal.payload_json

This code is illustrative. It omits database transaction handling, timestamp parsing, hash implementation, reviewer authentication, and framework-specific resume mechanics. canonical_hash represents an application implementation that serializes a defined structure consistently before hashing it. A hash detects a mismatch between the stored payload and its recorded digest; it does not prove who approved the proposal, prevent unauthorized database writes, or replace access controls.

Expiry is a hard boundary, not a hint to the user interface. A proposal that was approved at 10:00 and expires at 10:30 should fail the resume check at or after the configured boundary, even if the reviewer’s browser still shows a success page. Do not extend expiry automatically during retries. If the business still wants the action, create a new proposal and obtain a fresh decision against current context.

Expected result: the execution path receives the stored payload only after all guards pass. If any guard fails, record the specific reason, leave the external order unchanged, and surface an actionable state such as expired, stale, or invalid_payload. A generic “agent failed” message hides the difference between an expired decision and a storage-integrity defect.

Step 5: Recheck the target, then execute with retry protection

Approval confirms a proposal, not the continued state of the outside world. Immediately before execution, retrieve the order again and compare its current version with expected_target_version. In this hypothetical example, if order PO-4821 is now version 18 rather than 17, mark the proposal stale and stop. The agent may create a new proposal based on version 18, but the old approval cannot be transferred to it.

The version check should happen as close as practical to the write. Ideally, the system of record accepts a conditional update such as “change quantity to 60 only if the current version is 17.” A separate read followed by an unconditional update leaves a race: another process could change the order between your check and your write. If the destination cannot perform a conditional update, document that limitation and consider an application lock or a reconciliation step, while recognizing that neither makes a non-transactional external API atomic.

Give each proposal one stable idempotency key for the external operation, derived or stored by the application. Reuse that same key when retrying the same attempt after a network timeout. Do not generate a fresh key for every retry, because the destination may then treat retries as new actions. Do not promise exactly-once external effects: a timeout can occur after the remote system commits a change but before your worker receives its response. The practical goal is to prevent avoidable duplicates and reconcile ambiguous outcomes.

Before calling the external system, atomically claim the proposal for execution, subject to its current state. This can be a guarded transition from approved to executing. If two workers resume the same workflow, only one should acquire the claim. The second should observe that execution is already in progress or complete and avoid sending an independent request.

A useful execution timeline is: decision recorded at 10:12; worker claims the proposal at 10:13; request is sent; connection times out at 10:13:05. At this point, do not mark the order as unchanged just because the worker lacks a response. Mark the result as unknown, query the system of record using the order ID or external operation reference, and reconcile before retrying. A retry with the same idempotency key is appropriate only if the destination’s behavior and the observed state support it.

Expected result: a version mismatch prevents execution, a repeated resume does not start a second independent operation, and an ambiguous timeout enters reconciliation instead of being reported as a definite failure or success. This is the point where the proposed action becomes a real external effect, so the failure path must be explicit in both state and operator-facing messages.

Step 6: Verify the business result after execution

A successful response from a tool or API answers a narrow question: the request was accepted according to that interface. It may not establish that the intended business state now exists. After execution, retrieve the order from its authoritative source and compare the relevant fields with the approved proposal. For this example, verify that the order identifier is still PO-4821, its quantity is 60, and the update corresponds to the intended operation.

Record verification separately from the execution attempt. If the external system reports success but a later read shows quantity 55, the action is not verified. Store the observed state and mark a mismatch for a person or reconciliation process to investigate. If the read is temporarily unavailable, record unavailable and retry verification according to an explicit schedule; do not tell the agent to claim completion merely because the write request returned.

Verification criteria should be specific enough to distinguish the intended result from an unrelated change. Checking only that “the order exists” is insufficient if the purpose was to change its quantity. At the same time, avoid comparing unrelated fields that may change through ordinary operations and generate false alarms. Define the business postcondition before implementation and have the process owner confirm that it represents the desired outcome.

A verified result should update the proposal and execution records so a resumed workflow can report a grounded outcome. If the result is uncertain, the agent should report that the request was submitted but the outcome is being checked, not say that it completed. This separates model-generated narration from evidence available from the system of record.

Expected result: the workflow can distinguish completed and verified, submitted but not yet verified, failed, and outcome unknown. Those states support different next actions. A verified update can close the task; an unavailable read can trigger reconciliation; a mismatch can route to an operator; an unknown write outcome should not trigger a blind new operation.

Step 7: Exercise the failure paths before rollout

Begin with a small set of deterministic tests around application logic and the external-service boundary. Test the approval lifecycle itself: a pending proposal cannot execute; a rejected proposal cannot execute; a decision for another proposal cannot authorize this one; a proposal at its expiry boundary is rejected; and a changed payload fails validation. These are acceptance conditions for the design, not claims that this example code has been run.

Then test concurrency and interruptions. Submit the same approval callback twice and confirm the decision remains singular. Start two resume workers and confirm only one claims execution. Simulate a timeout after the destination may have accepted the request, then confirm that the workflow queries state before deciding whether to retry. Change the order version while the review is pending and confirm the old proposal becomes stale.

Test verification failures separately from execution failures. A write can succeed while the subsequent read is unavailable. A read can return a value that does not match the requested quantity. The user-facing status should reflect those distinctions, and the workflow should retain enough identifiers for an operator to find the external order without reconstructing the entire agent conversation.

These checks concern this approval boundary. Tool interface changes and compatibility checks are a separate engineering concern; see Tool Changes Can Break Agents: Add Contract Tests to CI for that distinct topic. Likewise, this walkthrough assumes a single proposal lifecycle and does not design coordination among multiple agents; that decision has different tradeoffs, discussed in Single Agent or Multiple Agents? Start with the Coordination Cost.

Troubleshooting common problems

A reviewer sees an expired item that still appears actionable. Disable the decision action in the interface when the record is expired, but enforce expiry again on the server when the decision is submitted and when the workflow resumes. A stale page can remain open after the expiry time, so hiding a button is helpful feedback but not authorization. Return the current status and offer a path to create a fresh proposal if appropriate.

An approval is recorded, but the agent never resumes. Check the proposal record, decision record and workflow reference independently. Determine whether the transaction committed, whether notification delivery failed, and whether the orchestrator is waiting on the correct proposal. A recovery process should be able to find approved proposals that are eligible for resumption without inventing a second decision. Do not “fix” the queue by changing an expired or rejected state to approved.

The system reports a payload-hash mismatch. Stop execution and investigate how the stored payload changed. Check serialization rules, database transformations and any code path that mutates proposals. Do not recalculate the hash over the changed value and continue; that would erase the evidence that the reviewed action and executable action may differ. Create a new proposal after correcting the record and confirming current target state.

The order changed while approval was pending. Mark the proposal stale and show the reviewer why it can no longer be applied. Refresh the order, create a replacement proposal based on the new version, and request a new decision. Even if the proposed quantity remains 60, the surrounding context may have changed enough to affect the decision.

A worker times out after sending the update. Set the attempt to an ambiguous state, query the destination, and reconcile against the postcondition. Reuse the same idempotency key if a retry is justified and supported by the destination’s behavior. If the result cannot be established, route the case for operational review rather than claiming success or issuing a new independent update.

A reviewer says the screen did not show the consequential field. Treat that as a review-interface defect. Confirm which stored fields were bound to the proposal, which fields appeared on screen, and whether the executable command included any parameter the reviewer could not inspect. Add the missing context or remove the hidden parameter from the action contract, then require a new decision for the affected proposal.

The model reports success while verification is pending. Keep business status under application control. Return an explicit result such as “submitted; verification pending” to the agent and constrain its response to that state. The agent should not transform a request acknowledgement into a claim about the final order record.

Worked scenario: quantity change with a competing update

Assume the supplier order begins at version 17 with a quantity of 40. The agent proposes 60 after receiving an internal request. The application records the target ID, version 17, canonical payload, payload hash and a hypothetical expiry of 30 minutes. The review screen displays both the existing and proposed quantities. A manager approves 60 at 10:12.

At 10:13, before the worker writes, a procurement operator changes the order to version 18 and quantity 50. The worker’s conditional update against version 17 fails. The correct result is not to force the quantity to 60, nor to reuse the manager’s approval. The proposal becomes stale, and the workflow reports that the order changed during review. If the agent still recommends 60, it must create a new proposal showing 50 as the current quantity and obtain a new decision.

Now consider a different timeline: the order remains at version 17, the worker sends the update, and the connection drops before a response arrives. The system cannot infer that the request failed. It records an unknown outcome and reads the order. If the order is version 18 with quantity 60, verification can establish that the intended state exists, subject to checking that the change corresponds to this operation. If it remains version 17 with quantity 40, the workflow can decide whether a retry is safe using the same operation identity and the destination’s documented behavior.

A counterexample shows why a bare approval flag is inadequate. The reviewer approves quantity 60. While the proposal waits, an agent or service mutates its payload to 600, but keeps the same proposal ID and approved status. A worker that checks only the status executes 600. The state machine appears to have approval, yet the action was never reviewed. Immutable proposal data, payload validation and a fresh proposal for every revision close that gap.

The acceptance criteria for this scenario are concrete: the approved quantity remains 60 in the stored proposal; expiry blocks resume at the configured boundary; a changed target version blocks the conditional update; duplicate callbacks do not create a second effective decision; concurrent workers do not launch duplicate independent operations; and a final read determines whether the intended state is verified. These conditions can be tested at the application boundary with controlled order states and simulated timeouts.

A compact implementation worksheet

Use this worksheet during design review. It is intentionally focused on the approval lifecycle rather than a general agent-readiness inventory. If an answer is unclear, resolve it before connecting the agent to a consequential tool.

Design question Decision to record Example for this walkthrough
What exact operation is reviewed? Action name and full set of effect-changing parameters Set order quantity to 60
What target state is the proposal based on? Version or reliable comparison rule Order PO-4821, version 17
How is the proposal bound to the decision? Immutable proposal ID and payload integrity check Proposal ID plus canonical payload hash
When does permission expire? Server-enforced timestamp and boundary rule Hypothetical 30-minute window
What invalidates an approval? Payload change, expiry, target-version change, or policy-relevant context change Version 17 changes before execution
How are retries identified? Stable operation identity and claim transition One stored idempotency key per proposal
What counts as a verified result? Authoritative read and explicit postcondition Order quantity is 60 on the intended order
What happens when the result is unknown? Reconciliation owner and next action Read current order state; do not blindly resubmit

For a printable review, copy the following summary into your design ticket and fill in the values for your own action:

  • The executable payload contains every parameter that can change the action’s effect.
  • The reviewer can see the material difference between current and proposed state.
  • Every proposal has its own immutable identifier, expiry and recorded target version.
  • Approval is rejected if the payload, target version, status or expiry check does not match.
  • Revisions create a new proposal rather than editing an approved one.
  • Duplicate decisions and concurrent resumes are handled as repeated events, not new permission.
  • Ambiguous external outcomes trigger reconciliation before any independent retry.
  • Completion is reported only after the defined business postcondition is verified.

Put the boundary in the application, not the prompt

A prompt can explain to an agent that certain actions require approval, but it cannot be the enforcement mechanism for that rule. The application should refuse to execute without a valid, unexpired decision bound to the exact stored proposal. This keeps the policy intact when the model retries, a worker restarts, a reviewer returns later, or an input changes.

Begin implementation with one action that has a clear target, observable state and manageable consequence. Define its proposal schema, expiry rule, stale-state behavior and verification condition. Then make the reviewer interface display that same proposal and ensure the execution path reloads it from trusted storage. Expand to other actions only when their parameters and postconditions are equally explicit; different operations may need different review context or expiry behavior.

If you need support designing the workflow boundary, state model and application integration for a production system, AI agent engineering is the relevant next step. The central implementation decision remains yours: an approval is permission for one exact action, under current conditions, before a defined deadline—not a general endorsement of whatever the agent does next.

WANT TO TALK THROUGH YOUR SITUATION?

Talk it through with us. Thirty minutes, no slides.

P
Written by the PADISO TeamSydney-based technology leaders who build what they advise on. Seven years of fractional CTO, architecture and AI work for enterprise, government and private equity.

Keep reading

All posts →

Let's talk.

Tell us what you are weighing up, whether it is a build, a roadmap or a deal. You will leave the call knowing your next step.

Book a 30-min callhi@padiso.co