DEV Community

Lukas Walter
Lukas Walter

Posted on Originally published at lukaswalter.dev

Human Approval as a System Boundary

A user approves a support email. Before the worker sends it, the agent rewrites the body and adds another recipient. The application still has Approved = true.

Human approval is an authorization control at the execution boundary. It must bind the decision to an exact operation: its destination, payload, and execution conditions. The flag says nothing about which version the user accepted.

I would design that agreement before adding the approval button. Once the reviewer clicks it, the worker needs enough stored information to finish without asking the model what the user meant.

Decide where human judgment helps

Suppose a support assistant can read a ticket, draft a reply, and send it. Company policy requires a support lead to review outbound replies because they can make commitments on the company's behalf. The application enforces that requirement.

Authorization first establishes which operations the requester may propose and for which resources. Application policy then chooses Allow, Deny, or RequireApproval. Approval is part of authorization: a support lead can authorize a reply that the requester cannot send alone. That decision must stay within the operation's permitted scope and cannot override a hard policy denial. This example uses one reviewer. Workflows with several reviewers need separate decisions and an aggregate policy result.

The broader enforcement design is in Trust Boundaries Around AI Features. This article follows one operation through review and dispatch.

The useful human decision is whether this reply should go to this customer. Routine authorized lookups need no interruption. The approval tip covers that distinction, though a read that exports protected data can still require stricter controls.

Prepare something the reviewer can approve

The assistant should finish the permitted preparation and validation before asking. That means a reply with resolved recipients and the intended attachments. Asking "May I send a response?" before one exists leaves the consequential details undecided.

Store the proposal as an immutable operation revision and use it for both the review screen and executor. That lets the application check the content and destinations it submits to the provider against what the person approved. Downstream processing and the recipient's mail client can still change the delivered message or its appearance.

For a hypothetical ticket, the reviewer might see:

Field Value shown for review
Operation Send a customer reply
Account and environment Example Support, production
Ticket T-1842, version 17
Sender support@example.com
Recipients To: customer@example.net; no CC or BCC; provider envelope has the same single destination
Subject Replacement for order O-731
Body Full text, including the promised replacement date
Attachments replacement-details.pdf, with access to the exact stored file
Approval scope Authorize one logical dispatch of this revision
Decide by 2026-09-06 10:15 Europe/Berlin (UTC+02:00)
Commit for dispatch by 2026-09-06 10:20 Europe/Berlin (UTC+02:00)

These fields come from the stored operation. A generated explanation can help the reviewer understand them, but must not replace or hide them. The review screen needs safe text rendering and access to the attachments.

Store attachments as immutable versions or protected snapshots. Finish transformations that can change approved details before review. Later transformations, such as provider footers or tracking-link rewriting, need fixed behavior within the approval scope or documented treatment outside it. A filename alone cannot bind approval to a file's contents.

Show actual addresses as well as display names. Header recipients and the SMTP or API envelope can differ, so the review must cover both. If approval depends on individual recipients, resolve mutable groups or aliases into fixed destinations before review. Approving a group address alone cannot establish who its members will be later.

This follows the transaction-review principle in the OWASP Transaction Authorization Cheat Sheet: show the significant operation data, protect it from modification, and enforce the decision on the server.

Bind the decision to a stored revision

An approval record needs a stable ID and a reference to the immutable operation revision. Record the requester and tenant, policy version, reviewer requirement, and decision. Store DecideBy and ExecuteBy as absolute UTC instants. Display the reviewer's time zone and offset. Keep the logical operation ID stable across attempts to dispatch that revision.

An executor deployment must preserve the meaning of stored operations. Record the operation type and a schema or handler-contract version when backward-compatible interpretation is not otherwise guaranteed. The review client submits identifiers, and the server loads the stored revision rather than accepting a replacement payload.

The model supplies candidate content. Authenticated context supplies identity and tenant scope. Application policy sets the approval requirement. The review request only identifies the stored operation and decision:

public enum ReviewDecision
{
    Approve,
    Reject
}

public sealed record SubmitReview(
    Guid ApprovalId,
    long ExpectedOperationRevision,
    ReviewDecision Decision);
Enter fullscreen mode Exit fullscreen mode

This DTO sketches the request. The endpoint must validate the decision, load the record inside the authenticated tenant, and authorize the reviewer, including any rule against self-approval. The client supplies neither reviewer identity nor replacement tool arguments.

Accept the decision only while the record is pending, the operation revision matches, and server time is strictly before DecideBy. Enforce this at the write, with separate approval-record concurrency control. ExpectedOperationRevision identifies the payload, not the ticket version, policy version, or concurrency token. Concurrent approve and reject submissions must commit at most one decision. Knowing an approval ID grants no authority to use it.

If the reviewer edits the body, save a new operation revision and supersede the old approval request. Present the final edited payload for confirmation. An explicit "Save and approve" flow can combine those steps only if it binds the decision to the exact edited revision the reviewer sees.

After approval, the worker loads that revision directly. Asking the model to reconstruct the email from conversation history would create another proposal.

Give waiting its own lifecycle

A reviewer may answer after a restart. Persist the pending request, return control instead of keeping a model request open, and provide a page or endpoint for checking its status. Give resumed execution its own runtime budget.

CommitForDispatch is the durable business transition that admits one logical operation to the dispatch process. A worker later claims an individual attempt. That gives the worker ownership of work already authorized by this gate:

Decision:
Pending -> Approved | Rejected | DecisionExpired | Superseded | Withdrawn

CommitForDispatch requires:
decision == Approved
and operation revision is still current and intact
and server time < ExecuteBy
and current policy and resource conditions permit execution
Enter fullscreen mode Exit fullscreen mode

An approved decision stays in the history even when ExecuteBy passes or a new proposal supersedes its operation revision. If the operation has not yet been admitted, it can no longer pass this gate. The record still shows who approved it. DecisionExpired means nobody decided in time. Withdrawn means an authorized requester withdrew the pending review without submitting a replacement.

Withdrawing the pending review prevents later admission. If the withdrawal ends the proposed operation, its logical state becomes Cancelled. The review remains Withdrawn to show that no reviewer decided.

The deadlines can differ: approval at 10:14 may authorize admission at 10:19, but not at 10:20. Check each deadline synchronously at its transition. A delayed cleanup job must not extend either one.

ExecuteBy bounds admission, not when the provider receives the request. An operation admitted at 10:19 could remain queued past 10:20. If freshness must hold until provider handoff, enforce a separate deadline and the relevant freshness checks before starting the first provider call. If nobody approves, the reply stays unsent. Reminders cannot authorize it.

Recheck before committing to dispatch

At 10:00, a support lead approves the reply for ticket version 17. At 10:02, another employee corrects the customer's email address. At 10:03, the worker picks up the approved send.

The stored decision is still useful history. The worker must now establish whether it can execute under the approved conditions.

Before admission, the application should:

  1. Load the approved operation and check its integrity, revision, decision, and ExecuteBy deadline.
  2. Reload the affected resources through the trusted tenant path and apply the configured requester and reviewer authority rules.
  3. Compare relevant resource versions and evaluate current policy, including whether it still accepts the recorded approval.
  4. Persist CommitForDispatch atomically with the approval and relevant resource checks that share its transactional store.

In this example, a relevant ticket change blocks the old send and requires a refreshed proposal. Do not silently send to the new address under an approval for the old one. If you choose a narrower version check than the entire ticket row, document which fields invalidate approval and make sure changes to them advance that version.

For this support workflow, I would require both requester and reviewer to retain their relevant authority until admission commits. Another workflow may accept the request and approval as historical acts, then execute under organizational or service authority. Each identity needs an explicit policy rule, and the executor needs authority to act. A current hard denial still stops execution.

When approval and relevant resource state share a transactional store, the admission transaction can conditionally check both. When the business effect and result also live in that transaction, they can commit together. EF Core concurrency tokens make updates conditional on the original version and report conflicts. They do not pull another service's state into the transaction. Blindly retrying a conflict with new values would discard the approved precondition.

If the ticket belongs to another service, use its conditional-operation or reservation support where available. A local admission transaction cannot atomically validate that remote ticket or an independent identity service. Without coordination, a time-of-check-to-time-of-use window remains. Document the risk and restrict or block the operation if policy cannot tolerate it.

Sending the email is external even when ticket and approval share a database. In this example, CommitForDispatch commits the business decision to dispatch the fixed revision. Coordinate conflicting local changes through that point. If policy must allow cancellation until provider handoff, the dispatch path needs an additional coordinated cancellation check. A queued record alone cannot provide that guarantee.

Once dispatch has begun, revoking approval cannot reliably recall the email. The UI should report the actual execution state and offer only cancellation or recovery actions the system can still honor.

Approval does not make retries safe

Two clicks on "Approve" must not create two logical dispatches. Record one decision, admit the operation once, and return its existing status when a request repeats. Each worker attempt gets a separate ID under that same logical operation.

Logical operation:
NotStarted --CommitForDispatch--> InProgress
NotStarted -> Blocked
NotStarted -> Cancelled
InProgress -> Succeeded | Failed | OutcomeUnknown
InProgress -> Blocked | Cancelled (before first provider handoff)
OutcomeUnknown -> Succeeded | Failed (new evidence)
OutcomeUnknown -> InProgress (safe retry permitted)
Enter fullscreen mode Exit fullscreen mode

Succeeded means confirmed provider acceptance of the approved request. Mailbox delivery and bounce tracking are separate. SMTP also distinguishes acceptance from later delivery or failure notification in RFC 5321, section 6.1. Failed means failure is established and the workflow will make no more attempts. A definitely failed network attempt does not by itself fail the logical operation. A new attempt may run while the logical operation remains InProgress.

Blocked means policy, freshness, or preconditions prevent the first provider call. Cancelled records a supported cancellation that succeeds before handoff. Both transitions must coordinate with dispatch so no worker can still start that call. Once a call may have been accepted, a policy change or cancellation request cannot establish either outcome. Keep an unresolved call in OutcomeUnknown until evidence establishes what happened.

Suppose the provider accepts the request and the worker crashes before saving the response. After restart, local state cannot establish acceptance. Blindly scheduling another attempt could submit the email again. Worker ownership does not resolve that uncertainty: an expired lease is no proof that a provider call stopped.

Recovery needs durable provider evidence or an idempotency contract that makes another attempt safe. Reuse the logical operation's idempotency key with the same payload, within the provider's scope and retention window. A new attempt ID must not become a new key. An outbox cannot supply deduplication that the provider lacks.

A saved provider reference or queryable client operation ID may establish what happened. The lost response may also have contained the only usable reference. Without evidence or safe idempotency, keep OutcomeUnknown and stop automatic retries. Investigation may never resolve it.

Admission before ExecuteBy can permit safe recovery of the same logical operation afterward if policy allows it. Current hard denials may stop future attempts, but they cannot undo a call the provider may already have accepted or change OutcomeUnknown to Blocked.

Retries Are Not a Recovery Strategy covers attempt recovery in more detail. In this example, the approval record establishes permission to dispatch the reply. It cannot tell the restarted worker whether the provider accepted the request whose response was lost.

Test what happens after the click

I would test the approval service and executor directly, without a model in the test. A prompt change should not affect any of these results:

Test Required result
Bypass approval or submit as a reviewer from another tenant Reject without sending or exposing the operation
Edit or tamper with the reviewed payload Require approval of a valid new revision, or reject an integrity failure
Duplicate approval, or concurrent approve and reject Commit one decision and admit at most one logical dispatch
Approve at or after DecideBy, or after supersession Reject even if cleanup has not run
Withdraw a pending review, then approve from a stale review page Reject the decision and never admit the operation
Approve in time; attempt admission at or after ExecuteBy Preserve Approved history; reject admission
Requester or reviewer loses required authority before admission Block under this example's policy; test historical-authority policies separately
Change local ticket state between validation and admission Reject the stale version in the shared transaction
Change remote state where conditional execution is supported The remote operation rejects the stale version
Change remote state after final validation but before local admission, with no coordination primitive Block if policy requires atomic freshness; otherwise assert admission succeeds against the validated snapshot despite the change, as policy explicitly permits
Restart after a provider call whose result was not saved Recover using durable evidence or valid idempotency; otherwise preserve OutcomeUnknown and do not resend
Deny the operation or miss its configured pre-handoff deadline after admission, but before the first provider call Record Blocked and prevent the first provider call
Cancel an admitted operation through a supported path before first handoff Record Cancelled only after preventing dispatch
Deny or request cancellation while a provider call has an unknown outcome Stop future attempts; preserve OutcomeUnknown until evidence resolves the call

For diagnosis, record approval, logical operation, and attempt IDs alongside revision, policy version, identities, deadlines, and state transitions. Keep sensitive payloads in protected storage with appropriate retention rather than copying email bodies into general application logs.

Also inspect the review experience. Track waiting time, edits and rejections, and approvals that miss ExecuteBy. Those observations help identify a queue that nobody can keep up with. An approval rate alone says little about whether anyone had enough context to review the action.

When human approval helps

Use it when an authorized person can assess a concrete operation before its consequences occur. A support lead can check whether the promised replacement is justified and whether the reply says what the company intends to promise. Give that reviewer enough context and time to decide, and define what the application does when nobody responds.

Do not add it to routine work already covered by an explicit automatic-execution policy. Do not offer an approval button for an action policy forbids. If the reviewer cannot inspect the payload or understand its effect, reduce the operation's scope or keep execution in an established manual process.

For an existing agent feature, start with one consequential tool. Write down the exact operation a person will review, what changes invalidate that decision, and how the executor proves it is carrying out the approved revision. Then test a changed payload and a worker crash: the first must block the old approval, and the second must preserve enough state to investigate or safely resume the original operation.

Related reading

Sources

Top comments (0)