Skip to content

    Forward Deployed Engineer

    Human Approval Gate for AI Agent Actions in Python

    Build a human approval gate for a sensitive agent action: bind the approval to the exact arguments with a digest, allow one use, and require a second person.

    By Published 8 min read

    Share

    This lesson is part of Forward Deployed Engineer: Agents, Tools and Guardrails. Enroll for free, then sign in, to read it in full.

    What you'll learn: how the capstone's ApprovalGate keeps a model from creating a quote draft on its own, why an approval is bound to a digest of the exact arguments, and how to walk one request through pending, approved and used.

    At a glance

    Figure 1. A sensitive action behind an approval gate

    A sensitive action behind an approval gateModel gateway to Orchestrator (proposal); Orchestrator to Approval service (request); Approver to Approval service (approves); Approval service to Quote system (creates)Model gateway: proposes onlyModel gatewayproposes onlyOrchestrator: WorkflowRunnerOrchestratorWorkflowRunnerApproval service: ApprovalGateApprovalserviceApprovalGateQuote system: a list in memoryQuote systema list in memoryApprover: a second personApprovera second personproposalrequestapprovescreates

    How to read this: the model's proposal can only become a pending request; the draft is created inside the gate, after a different person approves.

    • An action that changes the world needs a person's approval, checked in code and not requested in the prompt.
    • The approval is bound to a SHA-256 digest of the exact arguments, so changing one value voids it.
    • An approval is used once, and the requester cannot approve their own request.

    Scenario

    Composite scenario, not a real company. At Lumen Industrial Supply, a fictional distributor, the assistant can read the catalog and may also propose a quote draft: 40 vibration sensors for one customer reference.

    The rule: the model may propose, a different sales person must approve, and the action that runs must be the one approved. Numbers are illustrative.

    What you will be able to do

    • Explain why approval is enforced in code and bound to the exact arguments.
    • Walk a request through pending, approved and used, and name the call for each change.
    • Show with a test that changed arguments, a second use and a self-approval are refused.
    • Name what the gate lacks: expiry, persistence and a block on approve after use.

    Prerequisites

    Core ideas

    Propose, approve, execute

    Figure 2. One quote draft from proposal to creation

    sequenceDiagram accTitle: One quote draft from proposal to creation accDescr: The model proposes a request for approval with arguments. The orchestrator validates them as QuoteDraftArgs and asks the gate for an approval, which returns pending with an id derived from the digest. The orchestrator stores the exact arguments and shows them to a second person. That person approves. The orchestrator then executes with the stored arguments, the gate compares digests, marks the approval used and appends the draft. participant M as Model gateway participant O as Orchestrator participant G as Approval gate participant U as Approver participant Q as Quote list M-->>O: request_approval with arguments O->>O: validate as QuoteDraftArgs O->>G: request(args, requester) G-->>O: pending, id from the digest O->>U: show the exact arguments U->>G: approve(id) G->>G: status approved O->>G: execute(id, stored args) G->>G: digests match, status used G->>Q: append the draft G-->>O: draft qd-1

    How to read this: the model appears only on the first line; everything after it is application code and a person.

    The runner never executes the action. On request_approval it validates the arguments, asks the gate for an approval and stops with the status awaiting_approval. The model cannot call create_quote_draft as a tool either: the registry refuses tools that are not read-only.

    NOTE

    Nothing in the capstone's API can create a draft. A real service would add its own authenticated route that calls execute.

    Bind the approval to the exact call

    Approving "a quote draft" is not enough, because the arguments could change before execution (a retry, a model edit, a bug). So the gate approves a fingerprint of the call.

    Verified: capstone/fde-product-discovery-copilot/app/workflow/approval.py

    class QuoteDraftArgs(BaseModel):
        sku: str = Field(pattern=r"^LUM-[A-Z]{2}-\d{3}
    quot;
    ) quantity: int = Field(gt=0, le=10_000) customer_ref: str = Field(min_length=1, max_length=60) def args_digest(action: str, args: QuoteDraftArgs) -> str: canonical = json.dumps({"action": action, "args": args.model_dump()}, sort_keys=True, separators=(",", ":")) return hashlib.sha256(canonical.encode()).hexdigest()

    The digest covers the action name and every argument. sort_keys=True makes the JSON independent of key order, and the compact separators remove whitespace. SHA-256 gives the same 64 hex characters for the same input. It is a fingerprint, not a secret.

    Predict first. Alice requests a draft for 40 units. A bug changes the quantity to 4,000 before execution. The approval was granted for 40. What happens at execute, and what is the approval's status afterwards?

    Answer

    execute raises ApprovalError with "These arguments are not the ones that were approved." No draft is created. The status stays approved, because a refusal does not use the approval up, so the correct call with 40 units still works (test_changed_arguments_are_refused_and_the_approval_survives).

    Three states, one rule each

    Figure 3. States of one approval

    stateDiagram-v2 accTitle: States of one approval accDescr: A request starts as pending. A second person with the right role approves it and it becomes approved. Executing with the same arguments makes it used. Executing a pending approval, a used approval, or an approved one with different arguments is refused and the state does not change. A requester approving their own request is refused. [*] --> Pending: request by a partner Pending --> Approved: approve by a second person Approved --> Used: execute with the same arguments Used --> [*]

    How to read this: every arrow has one precondition, and any call that does not satisfy it raises an error and leaves the state as it was.

    Call Precondition Refused with
    request The requester's role may create_quote_draft ForbiddenError
    approve The approver's role may approve_quote_draft, and is not the requester ForbiddenError, ApprovalError
    execute Status is approved and the digest matches ApprovalError

    Verified: capstone/fde-product-discovery-copilot/app/workflow/approval.py

        def execute(self, approval_id: str, args: QuoteDraftArgs) -> dict[str, object]:
            approval = self._get(approval_id)
            if approval.status != "approved":
                raise ApprovalError(f"Approval {approval_id} is {approval.status}, not approved.")
            if approval.digest != args_digest(ACTION, args):
                raise ApprovalError("These arguments are not the ones that were approved.")
            approval.status = "used"  # single use
            draft = {"draft_id": f"qd-{len(self.quote_drafts) + 1}", **args.model_dump()}
            self.quote_drafts.append(draft)
            return draft

    The gate stores a digest, not the arguments. Keep the arguments the person was shown and pass those to execute.

    WARNING

    approve does not check the current status. An approver could call it on a used request and set it back to approved, which would allow a second draft. A production gate must refuse that. The test test_a_used_approval_should_not_be_approvable_again is marked as an expected failure to document it.

    Worked example

    Alice proposes 40 units of LUM-VS-100 for cust-1; Bob, a second partner, approves. Canonical JSON: {"action":"create_quote_draft","args":{"customer_ref":"cust-1","quantity":40,"sku":"LUM-VS-100"}}.

    Assumptions: illustrative values; the digests below were computed by running the capstone's args_digest.

    Step Who Call Result
    1 Model proposes request_approval Runner stops, awaiting_approval
    2 Orchestrator request with 40 units Id apr-2e8ce2893229, pending
    3 Alice tries approve herself Refused: "The requester cannot approve their own action."
    4 Bob approve approved
    5 Orchestrator execute with 4,000 units (digest ba489db39935...) Refused, still approved
    6 Orchestrator execute with the stored 40 units Draft qd-1, used
    7 Orchestrator execute again Refused: "is used, not approved."

    The approval id is the first 12 characters of the digest.

    At the customer: a security reviewer asks, "Can the AI send a quote on its own?" A good answer says no: the model can only create a pending request, a second person approves it, the action runs with exactly the approved arguments, and each approval works once. Name what is missing: expiry, a database, an audit trail.

    Build it

    Verified: capstone/fde-product-discovery-copilot/lessons/M4-L5/m4_l5_approval_flow.py

    def propose(gate: ApprovalGate, proposed: dict[str, object], requester: Identity) -> PendingQuote:
        args = QuoteDraftArgs.model_validate(proposed)  # bad arguments from the model stop here
        return PendingQuote(args, gate.request(args, requester))
    
    
    def review_text(pending: PendingQuote) -> str:
        """The screen the approver reads: the exact arguments, not the model's description of them."""
        a = pending.args
        return f"{ACTION}: {a.quantity} x {a.sku} for {a.customer_ref} [{pending.approval.approval_id}]"
    
    
    def decide(gate: ApprovalGate, pending: PendingQuote, approver: Identity, approve: bool) -> dict[str, object] | None:
        """Approve and execute with the stored arguments, or do nothing. A rejection leaves the request pending."""
        if not approve:
            return None
        gate.approve(pending.approval.approval_id, approver)
        return gate.execute(pending.approval.approval_id, pending.args)
    cd capstone/fde-product-discovery-copilot
    ./.venv/Scripts/python -m pytest lessons/M4-L5 -q

    Decision exercise. Fewer clicks wanted. Options: (A) every draft needs a second person's approval; (B) drafts under a quantity threshold skip approval; (C) the requester confirms their own draft in the UI. Choose one for the pilot and say what evidence would change your mind.

    Model answer

    Choose A for the pilot. C is the requester agreeing with themselves, which the gate refuses on purpose. B can be right later, but a quantity is a crude proxy for risk. First collect data: how often approvers change or reject a draft, and what a wrong draft costs. If almost none are rejected below a level, enforce that threshold in code and review it monthly.

    Explain it back. Explain to a customer's security reviewer, in three sentences, why the approval is tied to a digest of the arguments. A good one says what could change in between, that a changed value gives a different digest so the call is refused, and that the approval is used once.

    Gate or no gate? (interactive when JavaScript is on)

    An approved quote sits unused for a long time, then execute runs with the stored arguments. What does this gate do?

    • Runs it: there is no expiry, so add one before production
    • Refuses it: approvals lapse on their own
    • Asks the model whether the quote is still wanted
    Answer

    Runs it: there is no expiry, so add one before production. The gate records no approval time, so nothing lapses. A production flow adds expiry.

    The model proposes a quantity above the schema's limit. Where does it stop?

    • At the approver, who must notice it
    • At validation as QuoteDraftArgs, before any request exists
    • At execute, when the digests differ
    Answer

    At validation as QuoteDraftArgs, before any request exists. The runner validates the arguments before it asks the gate for an approval. A digest only exposes a change made after approval.

    What should the approver's screen show?

    • The model's one-line description of the quote
    • The approval id and the requester's name
    • The exact arguments the digest covers
    Answer

    The exact arguments the digest covers. The approver must read what will run, not the model's description of it.

    Exercise

    Add expiry to the gate. In lessons/M4-L5/, write a subclass of ApprovalGate whose approvals stop working a fixed time after approval. Use a fake clock.

    Acceptance criteria:

    • An approval executes at exactly the limit and is refused one second after it.
    • A refused execution creates no draft.
    • The limit counts from approval, not from the request.

    Done when three tests pass without any sleeping.

    Hint 1: what to remember

    The gate does not record when approve ran. Override it and note the time.

    Hint 2: where to check

    Override execute and compare clock() - approved_at with the limit before calling the parent.

    Common mistakes

    • Putting the rule in the prompt. A crafted document can talk the model out of it. Check in code.
    • Approving an action, not a call. Bind the approval to a digest of the arguments.
    • Re-reading arguments from the model at execution. Store and use the arguments the person saw.
    • Letting the requester approve. One person is then both checks. Require a second identity.

    Knowledge check

    1. Why does the gate store a digest and not the approved arguments?

    The digest is enough to tell whether a later call is the same call. The application must keep the arguments the person saw, because re-reading them from the model loses the guarantee.

    2. A refused `execute` leaves the status at `approved`. Is that a bug?

    No. A refusal means the call was wrong, not that the approval was spent, so the correct call can still succeed once. The state changes to used only after the digest matches and the draft is created.

    3. Why is `create_quote_draft` not registered as a tool?

    The registry accepts only read-only tools, so the model cannot call an action as a tool. The only route to the action is request_approval, which ends in a pending request.

    4. The approval id is the first 12 characters of the digest. What does that give you, and what does it risk?

    The same arguments always give the same id, so a repeated request is easy to recognise. The risk is that a new request with the same arguments replaces the stored one and starts again as pending. That fails safe, but a real system should key requests by a unique id.

    5. Name two things a production approval flow needs that this gate lacks.

    Expiry, so an old approval cannot run weeks later, and persistence, so a restart does not lose pending requests. An audit record is also needed.

    You can now

    • Explain why approval is enforced in code and bound to the exact arguments.
    • Walk a request through pending, approved and used and name the call for each step.
    • Write tests for changed arguments, a second use and self-approval.
    • List what a production approval flow adds.

    Further reading

    Last verified: 2026-11-02

    Next

    Testing tool failure and malformed input

    Enroll to keep reading

    Lesson 5 of 8 · Forward Deployed Engineer: Agents, Tools and Guardrails

    Build a human approval gate for a sensitive agent action: bind the approval to the exact arguments with a digest, allow one use, and require a second person.

    In the rest of this lesson

    • Worked example
    • Build it
    • Exercise
    • Common mistakes
    • Knowledge check
    • You can now

    Enrolling is free. It unlocks every lesson in Forward Deployed Engineer: Agents, Tools and Guardrails, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments