Skip to content
Harness Engineering2026-09-1013 min read

AI Agent Approval Boundary: Draft Accepted Isn't Purchase Made

An AI agent approval boundary means accepted_draft carries no supplier receipt. Learn to separate validation from execution.

Key takeaways

  • A validator returning accepted_draft has confirmed structure and business rules, not that any external action occurred.
  • Binding approval to a specific data snapshot means a changed stock revision after approval must trigger a fresh validation, not a silent write.
  • Idempotent execution design matters because resumed or retried agent steps can rerun side effects unless the execution boundary checks state before acting.
  • A repair loop that regenerates a draft after rejection is not the same operation as a supplier call; only one of them should be allowed to change the world.
  • Stale-snapshot and budget-exceeded rejections should stop for human review rather than silently retrying, because no repair can supply missing authority.

Rod Rivera

Author

AI Agent Approval Boundary: Draft Accepted Isn't Purchase Made

Rod's note — read with a pencil; the margins are for you.

U.S. teaching example: Amounts are fictional USD prices, stored as integer cents. This is a U.S. version of the shop exercise, not a currency conversion or a record of a real charge.

What an accepted replenishment draft proves

A shop's ordering system asks a model to draft a replenishment order. The model returns a JSON object: six tubs of vanilla, four of strawberry, total cost 2,600 cents. A validator checks the object against the shop's actual stock and budget. Every check passes. The validator returns a status field: accepted_draft.

Has the shop bought stock?

The shop has an accepted proposal. There is no supplier confirmation, order number or charge against an account. To buy stock, a separate component must check the authority to act and submit the order. The inventory example below makes that handoff explicit, including what should happen when the stock changes after approval.

Two functions, two very different jobs

Give validation and execution separate interfaces. One function takes a proposal and returns a judgment about whether that proposal is valid: correctly typed, internally consistent, within budget, matching known facts. A second function takes an approved proposal and does something to the outside world: places an order, calls a supplier's API, moves money. A pure validator can be called repeatedly without creating an order. Its answer stays the same when both the proposal and all trusted reference data stay the same. The second function is not safe to call twice, because calling it twice might mean ordering the stock twice.

The teaching case behind this article, from the Zero-Employee Organization (ZEO) inventory course, builds the first function in careful detail and stops there on purpose. In Validate the Business Decision and Bound the Repair Loop, a shop snapshot lists three products with stock levels, replenishment targets, and prices in cents. A model proposes an order. A validator checks four things in sequence: that the SKUs mentioned actually exist in the catalog, that the proposed quantities exactly match what is required to reach target stock (no more, no less), that the total cost fits inside a configured budget, and that the snapshot referenced by the draft is the current one rather than a stale copy. When all four checks pass, the function returns a dictionary with a status field set to accepted_draft, the accepted line items, and an estimated total in cents.

Six vanilla tubs at 250 cents and four strawberry tubs at 275 cents: 1,500 plus 1,100 is 2,600 cents, and the configured budget is 3,000 cents, so the draft passes. Nothing in that returned dictionary contacts a supplier. There is no purchase order number because there is no purchase order system in the picture at all. The function that produced accepted_draft never imported a network client, never held credentials, and never had the ability to spend money even if its authors had wanted it to.

Naming the four states so they cannot be confused

The confusion this article addresses usually happens because people talk about "the agent's output" as a single thing, when it actually passes through at least four distinguishable states on its way to having real-world effect.

StateWhat it meansWhat confirms it
GeneratedA model produced a JSON object claiming to be an orderThe object exists, nothing more
ValidatedThe object's shape and values were checked against schema and business rulesaccepted_draft status, computed total matches expectation
ApprovedA human or an authorized policy signed off on this specific draftA recorded approval tied to a draft identifier and a data version
ExecutedA supplier actually received and confirmed an orderA supplier receipt, order confirmation, or equivalent external record

The worked example in the course lesson gets you cleanly to "validated." It never claims to reach "approved" or "executed," and that omission is deliberate rather than an oversight to patch. The lesson's closing callout says it directly: a passed schema, an accepted draft, and a completed purchase are three different claims. This article's job is to extend that boundary one step further, because the natural next question a team asks is: fine, so what does the code for the next two states actually look like, and what has to be true before you let a system move from one to the next?

Four states between a generated draft and an external action

Bind the AI agent approval boundary to exact data

Suppose your team adds a human approval step after accepted_draft. Lucy, the shop's owner, looks at the draft and clicks approve. What exactly did she approve? If the answer is "the general idea of restocking vanilla and strawberry," you have a problem, because stock levels change between the moment a draft is validated and the moment someone gets around to approving it. If one vanilla tub sells in that interval, the shortage moves from six to seven and the correct order total moves from 2,600 cents to 2,850 cents. An approval that does not reference the exact snapshot it was granted against cannot tell the difference between approving today's numbers and rubber-stamping yesterday's.

The fix is to bind the approval to a draft hash and a data revision together, then check both again immediately before execution. Here is a small, complete, in-memory example that shows the mechanism. It uses only the Python standard library and keeps everything in variables rather than a database, so treat it as a toy illustrating the check, not a concurrency-safe production design.

python
from dataclasses import dataclass

@dataclass
class ApprovalRecord:
    draft_hash: str
    snapshot_revision: int

@dataclass
class EligibilityResult:
    status: str
    may_submit: bool

def check_if_current(approval: ApprovalRecord, current_draft_hash: str,
                        current_snapshot_revision: int) -> EligibilityResult:
    if approval.draft_hash != current_draft_hash:
        return EligibilityResult(status="stale_approval", may_submit=False)
    if approval.snapshot_revision != current_snapshot_revision:
        return EligibilityResult(status="stale_approval", may_submit=False)
    return EligibilityResult(status="eligible", may_submit=True)

approval = ApprovalRecord(draft_hash="d17", snapshot_revision=17)

fresh = check_if_current(approval, current_draft_hash="d17",
                            current_snapshot_revision=17)
stale = check_if_current(approval, current_draft_hash="d17",
                            current_snapshot_revision=18)

print(fresh)
print(stale)

The expected output is EligibilityResult(status='eligible', may_submit=True) for fresh and EligibilityResult(status='stale_approval', may_submit=False) for stale. Both comparisons match in the first case. In the second, the current snapshot has moved to revision 18 while the approval still names revision 17. This function makes no supplier call in either case. It checks eligibility only; it neither records an executed order nor prevents duplicate submission. The short d17 value is a placeholder identifier for the illustration, not a computed content hash.

Notice what this toy version leaves out. Real execution needs an idempotency key so that a network retry after a timeout does not place the same order twice even when the first call actually succeeded. A conditional database write can reserve the local operation against the expected revision. That transaction cannot usually make a remote supplier call atomic with local state. Use a durable operation record and reconcile the supplier outcome after timeouts, according to the supplier's documented idempotency behavior. LangGraph's documentation on interrupts makes a closely related point for agent workflows generally: resuming an interrupted node can rerun work that happened before the interrupt, so side effects need to be written idempotently rather than assumed safe on replay (LangGraph: interrupts). A human approval pause does not remove that requirement. It just adds one more place where stale state can slip in undetected if you do not check for it explicitly.

A canceled approval does not undo a supplier call

The eligibility function above has nothing to undo because it sends no order. Once a real supplier accepts an order, reversing it requires the supplier's cancellation or return process. Preserve the original receipt and record the later cancellation outcome separately.

A generated draft passes validation, approval and a fresh snapshot check before submission with an operation identity and a recorded supplier outcome.

ResultLocal recordNext action
Snapshot changedApproval references an old revisionRevalidate and obtain approval for the changed proposal
Supplier confirmedReceipt linked to the operation identityRecord the accepted order
Request timed outSubmission outcome remains unknownQuery or retry using the same operation identity under the supplier's contract
Cancellation requestedOriginal receipt retainedRecord the separate cancellation result

The record should distinguish a refused attempt from an unknown supplier outcome. Otherwise, a timeout can look like a clean rejection, and the next worker may create another order while the first is already being processed. This is why the short eligibility function belongs inside a larger operation protocol. It is useful on its own for checking stale approval; it is insufficient as an execution system.

Why the repair loop is not the same thing as execution

The course lesson also builds a bounded repair loop: if a proposal is rejected for a fixable reason, such as a type error, the system can ask the model to try again, up to two attempts total. It is tempting to treat this loop as reassuring evidence that the system is careful, and then let that same carefulness quietly imply the system is safe to wire up to a live supplier. The two are unrelated. A repair loop governs how many times you regenerate a draft before giving up. It says nothing about whether an accepted draft should trigger a purchase. The lesson is explicit that certain rejections, specifically a stale snapshot or an exceeded budget, stop immediately for human review rather than feeding back into another generation attempt, because no amount of regenerating the JSON can supply an authorization that does not exist yet.

Anthropic's tool-design guidance discusses tool definitions, responses and evaluation (Anthropic: writing tools for agents). Applied here: if your validation function and your execution function share a name, a return type, or a calling convention that makes them look interchangeable to whoever is wiring up the agent's tool list, you have built a trap into the interface itself, independent of how careful any single developer intends to be. Keep them visibly distinct. A validate_draft tool should be able to return only judgments. An execute_order tool should require an approval record as an argument, and should be the only function in your codebase permitted to import whatever client talks to the supplier.

First: validate structure and business rules

Run the draft through schema checks and comparisons against trusted state (stock, budget, catalog). Produce accepted_draft or a named rejection code. No external call happens here.

Second: record an explicit approval

Capture a human or policy decision that references the specific draft hash and the specific data snapshot revision it was checked against. An approval with no reference to exact data is not a usable approval.

Third: recheck before acting

At the moment of execution, compare the approval's stored draft hash and snapshot revision against the current values. Only an exact match may proceed to the supplier call.

Fourth: treat the supplier response as the only receipt

Do not treat accepted_draft, or even a recorded approval, as proof that stock changed. A confirmation establishes what the supplier accepted. A timeout leaves an unknown outcome to reconcile; it does not establish failure or permission to create a new order.

What would falsify this design

A useful boundary should be falsifiable: you should be able to say what evidence would show it has failed. Three tests do that here. First, pass a stale approval to the eligibility function and confirm may_submit is false. In a separate integration test, verify that a denial prevents the caller from reaching the supplier adapter. Second, feed it a draft hash that does not match, simulating someone approving one draft and a different draft somehow reaching execution; this should also refuse. Third, and this is the one teams skip, check what happens on a network retry after a timeout during the real execution call: if the same order can be submitted twice because the retry has no idempotency key, the boundary you built in application code is undone by a transport-layer accident. None of these are exotic failure modes. They are the ordinary ways a system that looks correct in a demo turns out to be unsafe under concurrent, retried, real-world use.

Quick check — A draft is approved against snapshot revision 17. Before execution runs, a concurrent sale updates stock to revision 18. What should the execution function do?

Check the decision

Building this into your own system

If you are wiring an agent into any process that eventually touches money, inventory, or another party's system, write down your four states before you write any execution code: generated, validated, approved, executed. For each transition, name the exact check that must pass and the exact evidence that confirms it happened. Treat "accepted_draft" or its equivalent in your own domain as a claim about structure and rules, never as a claim about the world having changed. Then build your execution function so that it takes an approval record referencing specific data, not a general go-ahead, and have it recheck that reference against current state before it does anything a rollback cannot cleanly undo.

Work through the full validation and repair-loop lesson

See the complete inventory validator, its seven rejection cases, and the bounded repair loop that this article's execution boundary builds on.

Ready to put an agent to work?

Join the Prof Rod newsletter for one educational lesson a week, with worked examples attached. It is free to register for and separate from the Zero Employee community.