Orchestrate

Report an issue

Something wrong, out of date or unclear? Tell us. Corrections are prioritized over new topics. You don't need to sign in.

0 / 2000

Don't include personal or confidential company details. Reports are reviewed with AI assistance.

Designing human approval into AI workflows

Choose how much a person must be involved in each AI action, enforce approval in code rather than in the prompt, and turn every override into a signal.

Updated Oct 23, 2026Foundational 9 minDeep 40 min
Foundational layer · 9 min read

The 60-second version

An AI system that works on SAP data will sooner or later propose a change: release a blocked order, post a correction, email a customer. The question is not whether a person should be involved. It is how much, for which actions, and how you make sure it really happens.

There are four useful patterns, from most to least human involvement:

  1. Draft. The AI writes something; a person edits and sends it. Nothing happens unless the person acts.
  2. Recommend. The AI suggests a decision with its evidence; a person decides and acts.
  3. Act with approval. The AI prepares the exact change; it runs only after a person with the right role approves that exact change.
  4. Act and report. The AI makes a small, reversible change on its own, tells someone, and makes undo easy.

You choose the pattern for each kind of action by two questions: how much harm if it's wrong, and how easy is it to undo. Then you enforce the choice in software, not in the AI's instructions, and you record every time a person disagrees with the AI, with a reason. Those disagreements are the best data you will get about where the AI is weak.

Why it matters to the business

Stay with the credit team's worklist of blocked sales orders from earlier Unit 9 topics.

  • Value comes from speed on the easy cases. If every proposal waits for a manager, the AI saves reading time but not waiting time. Matching the pattern to the risk lets low-risk work flow and keeps people for the decisions that need them.
  • Risk comes from the hard cases. Releasing an order for a customer far over their credit limit is a financial decision. OWASP's guidance on excessive agency says high-impact actions should need a person's approval before they run, and that the system, not the language model, should decide whether an action is allowed.
  • An approval nobody reads is not a control. Research on decision support calls this automation bias: people over-rely on automated advice, and more so under high workload and time pressure. A clerk with 200 approvals a day and a green "approve all" button is not overseeing anything. The EU AI Act's human oversight article asks that people overseeing high-risk systems stay aware of this tendency and can override or reverse the output.
  • Overrides are cheap feedback. Every time a credit manager rejects a proposal and picks a reason, you learn something specific: the AI ignores promised payments, say. Capturing that reason costs one click.
  • Audit asks one question: who decided what, based on what? An approval record that names the person, their role, the exact change and the evidence they saw answers it.

How SAP does it

As of October 2026, SAP offers approval and task capabilities at several layers. None of them is an AI feature on its own; they are where an AI proposal should land.

  • Inside the business process. SAP's credit management training describes how an automatic credit check blocks an order and assigns a credit case to an analyst, who works it in the Manage Documented Credit Decisions app with Release, Recheck and Reject, a rejection needing a reason and a note. That is already a human approval step; an AI should prepare evidence for it, not route around it.
  • Workflow inboxes. In SAP S/4HANA, flexible workflows send approval tasks to My Inbox, where approvers can approve, reject, claim, suspend or forward a task, set a substitute, and see the history. The recipient needs the authorization for the task.
  • SAP Build Process Automation lets you build approval forms in a low-code process builder. The process branches on Approve or Reject, and the task appears in the approver's My Inbox with a due date.
  • SAP Task Center collects tasks from SAP and non-SAP applications into one list. SAP lists approval use cases from SAP S/4HANA and SAP Build Process Automation among its providers, and notes that not every provider use case is supported.
  • Joule agents. SAP's Joule Studio course lists a Human in the Loop tool that pauses an agent's workflow and asks a user for approval or input on critical decisions. Joule Studio's status and terms were changing in October 2026; see Joule and Joule Studio.

Choosing the pattern: a decision guide

Ask the two questions for each kind of action, not for the whole system. One agent can use all four patterns.

Easy to undo Hard to undo
Low harm if wrong Act and report (assign a worklist owner, add an internal note) Act with approval (send a routine customer email: once sent, it's sent)
High harm if wrong Act with approval (change a delivery date on a large order) Act with approval, senior role; or recommend only (release a credit block far over the limit)

Then apply three adjustments:

  • Anything that leaves the company (emails, supplier messages, payments) counts as hard to undo.
  • Size moves the line. A reversible change on a very large order still needs approval. Set the amount threshold with the business owner.
  • Draft and recommend are for work where the person does the final act in their own tool. They change nothing in SAP, so they need no approval token, but they still record the person's decision.

A good approval screen shows four things: the exact change, the evidence the AI used, its confidence, and a reason list for overrides. It sits in the inbox people already use, not in a new tool.

Questions to ask

  • For each action the AI can propose, which pattern applies, and who agreed it?
  • Where is approval enforced: in the AI's instructions, or in code the AI can't reach?
  • Does the approver see the exact change that will run, and is that what actually runs?
  • How long does an approval stay valid, and what happens if the data changes in between?
  • Which inbox will approvers use? Is it the one they already work in?
  • What reasons can an approver give for rejecting, and who reads them each week?
  • What share of proposals are approved without change? If it's near 100%, is anyone really checking?
  • Who can stop the AI entirely, and how fast?

Common misconceptions

  • "Human in the loop means a person approves everything." That turns people into a rubber stamp. Match the pattern to risk and reversibility; keep approvals for the decisions that need them.
  • "The prompt tells it to ask first, so it's safe." A prompt is a request. Injected text or a model error can ignore it. The code that makes the change must refuse without a valid approval.
  • "A high approval rate proves the AI is good." It can also mean people stopped looking. Watch time-to-decide and sample approvals for review.
  • "Overrides are failures to hide." They are the most useful data you have. A reason code per override tells you what to fix.
  • "The AI approval replaces the SAP approval." For processes that already have one, such as documented credit decisions, the AI prepares the case. The SAP step stays the decision.

Key terms

All course terms, A to Z, in the glossary

  • Human in the loop: a design where a person takes part in a decision or action before or after the AI acts.
  • Draft, recommend, act with approval, act and report: four patterns for how much a person is involved, from most to least.
  • Reversibility: how easily a change can be undone, and at what cost.
  • Approval token: a signed record that a named person approved one exact action, until a set time.
  • Override: a person's decision that differs from the AI's proposal.
  • Reason code: a short, fixed reason chosen when overriding, such as "customer payment expected".
  • Automation bias: the tendency to rely on automated advice too much.
  • Audit trail: a record of who proposed, decided and carried out each action, in order.

Check yourself

Pick one answer for each question. The explanation appears after you choose.

1Which two questions decide how much a person must be involved in an AI action?

Answer: A. The pattern follows from harm and reversibility, judged per kind of action. Model accuracy and cost matter elsewhere, but a very accurate model can still make a costly, irreversible mistake.

2Your vendor says approval is safe because the system prompt tells the agent to ask before releasing orders. What should you ask for?

Answer: A. A prompt is a request that a model error or injected text can override. OWASP's guidance is to decide what is allowed in the downstream system, so the code making the change must refuse without an approval.

3Credit managers approve 99% of AI proposals within a few seconds each. What is the most likely concern?

Answer: D. Very fast, near-total approval is a sign of automation bias, which grows with workload and time pressure. Sample approvals for review and look at time-to-decide before concluding the AI is perfect.

4An AI could assign a worklist owner for a small blocked order. The change is internal and easy to undo. Which pattern fits?

Answer: D. Low harm and easy reversal is where act-and-report fits: the change happens, someone is told, and undo is one step. Requiring senior approval here wastes the approver's attention on decisions that don't need it.

5In SAP S/4HANA, a credit-blocked order already goes to a credit analyst in Manage Documented Credit Decisions. Where should an AI fit?

Answer: D. The documented credit decision is already the human approval step, with Release, Recheck and Reject. An AI adds value by preparing the case; a second approval path would compete with the real one.

6Why capture a reason code each time a person overrides the AI?

Answer: B. Each override with a reason is specific feedback, such as "the AI ignores promised payments". Counting reasons by action kind shows what to fix in the data, rules or prompt.

7What makes an approval record useful to an auditor?

Answer: D. An auditor asks who decided what, based on what. The record needs the person, their role, the exact change and the evidence they saw, kept by the system rather than the AI.
Deep layer · 40 min read

Mental model

An approval is a key, not a conversation. The model proposes; your code turns the proposal into an exact, hashed action; a person's decision turns into a signed token for that action alone; and a separate execute step, which the model cannot call, checks the token before anything changes.

Everything else in this topic hangs on that split:

  • The policy (which pattern applies) is code that reads facts about the action, not the model's opinion of itself.
  • The inbox shows the exact action and its evidence, because the token will cover exactly that.
  • The execute step is the only path to a change, so a prompt injection, a model mistake or an edited proposal can't skip the person.
  • The audit trail and override log are by-products of the same records, not a separate effort.

Multi-step agents added an approval checkpoint to one agent loop. Agent permissions and SAP authorizations made sure no agent token can reach the approving step. This topic is about the design around them: choosing the pattern, building the token and the execute step, and making the human side work.

How it works

Four patterns, mapped to what the AI automates

Parasuraman, Sheridan and Wickens split automation into four functions: gathering information, analysing it, selecting a decision, and carrying it out. Each can be automated to a different level. The four patterns are points on that scale:

Pattern AI does Person does Changes SAP without a person?
Draft Gathers, analyses, writes the text Edits, sends No
Recommend Gathers, analyses, proposes a decision Decides and acts No
Act with approval Everything up to the exact action Approves or rejects that action No
Act and report Everything, including the action Reviews afterwards, can undo Yes, within limits

A policy in code

The mode is a function of facts about the action. In the lab:

def choose_mode(kind, amount):
    p = POLICY[kind]
    if not p["changes_sap"]:
        return "draft" if p["leaves_company"] else "recommend"
    if p["reversible"] and amount <= APPROVAL_AMOUNT:
        return "act_and_report"
    return "act_with_approval"

Three rules make this robust. The policy is a table the business owner can read. The model never sets the mode or the approver role. And unknown action kinds fail with an error, because POLICY[kind] raises instead of defaulting to something permissive.

Approval tokens and a separate execute step

sequenceDiagram
  participant A as Triage agent
  participant Q as Proposal queue
  participant P as Approver
  participant X as Execute step
  participant S as SAP
  A->>Q: proposal + exact action
  Q->>Q: mode and role from policy, hash of action
  P->>Q: accept as CREDIT_MANAGER
  Q->>Q: sign token: id, hash, person, role, expiry
  X->>Q: approved items
  X->>X: signature, hash, expiry, not used?
  X->>S: change with idempotency key
  X->>Q: executed, audit entry

The token is a small signed message. It holds the proposal ID, a SHA-256 hash of the exact action arguments, the approver, the role, an expiry time and a random nonce (a one-time value). Your server signs it with HMAC, a keyed hash that only holders of the secret key can produce. The execute step refuses if:

  • the signature doesn't match (forged token, or the key changed);
  • the action's hash differs from the one approved (someone or something edited it);
  • the approval has expired (the data may have moved; ask again);
  • the nonce was already used (a replay).

Keeping execute separate from decide matters for two reasons. The model can be given a "propose" tool but never an "execute" tool. And frameworks that pause and resume can repeat work: LangGraph's documentation warns that on resume the whole node runs again from its start, so a side effect placed before the interrupt happens twice. A separate execute step with an idempotency key avoids both problems.

Audit trail: an append-only chain

Each audit entry stores the previous entry's hash, and its own hash over everything including that link. Edit, delete or reorder a line and the chain breaks at that point. In the lab it is a file; in production you'd write to storage that can't be rewritten by the application itself, because anyone who can rewrite the file can also recompute the hashes.

The human side: inbox, evidence, overrides

  • Put tasks in the existing inbox. Approvers already work in My Inbox or SAP Task Center. A second inbox gets ignored.
  • Show evidence, not just a verdict. The Goddard review found that presenting information rather than direct recommendations, and showing updated confidence levels, were among the mitigators for over-reliance.
  • Make accept one click and override two. Override needs a reason from a short list, plus an optional comment.
  • Feed overrides back. Each override becomes a row: what the AI saw and proposed, what the person did, and why. Counted by action kind and reason, these rows tell you what to fix. They can also become test cases for evaluating agents. Treat comments as data, never as instructions for the next run.

Build it yourself: an approval gate for the blocked-orders triage

You will build unit09/approval_gate.py. It takes the triage agent's proposals for five blocked orders, picks a pattern for each from a policy table, shows an inbox with evidence, records decisions with reason codes, issues signed approval tokens, and carries out approved changes in a separate execute step. Then you will try to break it: the wrong role, a tampered action, an expired approval and an edited audit log.

flowchart LR
  T[propose] --> M{policy}
  M -->|draft / recommend| I[inbox]
  M -->|act with approval| I
  M -->|act and report| D[done, reported]
  I --> DEC[decide<br/>accept or reject + reason]
  DEC -->|approved| X[execute<br/>checks token]
  DEC -->|rejected| O[override log]
  D -->|undo + reason| O

Before you start: complete Set up your computer for this course and Set up for Unit 9, which creates the unit09 folder. This lab uses only built-in Python modules, so there is nothing new to install. It continues the blocked-orders example from Agents from first principles and Multi-step agents.

What you need

  • Your course folder orchestrate-course with its .venv, from the setup topics.
  • About 40 minutes.
  • No account and no cost. The triage agent is a rule-based stand-in, so the whole lab runs offline on made-up orders with --sample.

Step 1: Open your course folder and turn on the virtual environment

  1. Open VS Code, choose File > Open Folder, and open orchestrate-course.

  2. Open a terminal: Terminal > New Terminal.

  3. If the prompt doesn't start with (.venv), turn it on:

    • Windows (PowerShell):

      .venv\Scripts\Activate.ps1
    • macOS / Linux:

      source .venv/bin/activate
  4. Check that the unit09 folder exists (same command on every system):

    python -c "import pathlib; print(pathlib.Path('unit09').is_dir())"

    You should see True. If you see False, right-click in VS Code's file list, choose New Folder and name it unit09.

Step 2: Save the script

  1. In VS Code's file list, right-click unit09, choose New File and name it approval_gate.py.
  2. Paste the code below and save. The table after Step 10 explains each part.
"""Unit 9: an approval gate for the blocked-orders triage, with approval tokens, a separate execute step,
an audit trail and an override log.

The triage agent proposes one next step per blocked order. This script decides HOW MUCH a person must
be involved (draft, recommend, act with approval, act and report), puts proposals in an inbox with their
evidence, records each person's decision, and carries out approved changes in a separate step that checks
a signed approval token. Every decision and action goes into a hash-chained audit log.

Commands (run from your course folder, with .venv turned on):
    python unit09/approval_gate.py reset                      # start clean (deletes this lab's files)
    python unit09/approval_gate.py propose --sample           # the triage proposals for five made-up orders
    python unit09/approval_gate.py inbox                      # what is waiting for a person, with evidence
    python unit09/approval_gate.py decide --id P1 --accept --by "Kim Lee" --role CREDIT_MANAGER
    python unit09/approval_gate.py decide --id P2 --reject --reason CUSTOMER_PAYMENT_EXPECTED \
        --by "Ana Diaz" --role HEAD_OF_FINANCE --comment "Payment of 20,000 promised for Friday"
    python unit09/approval_gate.py execute                    # carry out approved actions (separate step)
    python unit09/approval_gate.py tamper --id P1             # change a proposal after approval (to test)
    python unit09/approval_gate.py undo --id P5 --reason WRONG_CLERK --by "Kim Lee"
    python unit09/approval_gate.py audit                      # verify the audit chain and print it
    python unit09/approval_gate.py report                     # acceptance and override rates, reasons

All data is made up. Nothing here reaches an SAP system. Built-in Python modules only.
"""
import argparse
import base64
import hashlib
import hmac
import json
import os
import secrets
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path

try:  # read APPROVAL_SIGNING_KEY from .env if python-dotenv is installed (course setup installs it)
    from dotenv import load_dotenv
    load_dotenv()
except ImportError:
    pass

HERE = Path(__file__).resolve().parent
LAB = HERE / "approval_lab"
QUEUE = LAB / "queue.json"            # proposals and their decisions
SAP = LAB / "sap_stub.json"           # stands in for SAP: what was actually changed
AUDIT = LAB / "audit.jsonl"           # append-only, hash-chained record
SIGNAL = LAB / "override_signal.jsonl"  # every case where a person disagreed with the AI
KEYFILE = LAB / "lab_signing_key.txt"  # lab only; production keeps the key in a secret store
REQUESTER = "triage-agent"            # the identity that proposes; it may never approve

# Made-up blocked orders in sales organization 1010, shaped like API_SALES_ORDER_SRV headers.
SAMPLE_ORDERS = [
    {"SalesOrder": "4711", "SoldToParty": "17100001", "TotalNetAmount": 41200.00, "TransactionCurrency": "EUR",
     "block": "credit", "credit_limit": 400000.00, "exposure_after_order": 412000.00, "open_overdue": 0.00},
    {"SalesOrder": "4712", "SoldToParty": "17100002", "TotalNetAmount": 88000.00, "TransactionCurrency": "EUR",
     "block": "credit", "credit_limit": 250000.00, "exposure_after_order": 295000.00, "open_overdue": 20000.00},
    {"SalesOrder": "4713", "SoldToParty": "17100003", "TotalNetAmount": 2300.00, "TransactionCurrency": "EUR",
     "block": "delivery", "note": "ship-to address has no postal code"},
    {"SalesOrder": "4714", "SoldToParty": "17100004", "TotalNetAmount": 15600.00, "TransactionCurrency": "EUR",
     "block": "billing", "note": "billing block set manually; no text"},
    {"SalesOrder": "4715", "SoldToParty": "17100005", "TotalNetAmount": 950.00, "TransactionCurrency": "EUR",
     "block": "delivery", "note": "same ship-to issue as last week; owner unclear"},
]

# The policy: each kind of action, judged on three questions. The mode follows from the answers.
POLICY = {
    "draft_customer_email":   {"changes_sap": False, "leaves_company": True,  "reversible": False},
    "recommend_next_check":   {"changes_sap": False, "leaves_company": False, "reversible": True},
    "assign_worklist_owner":  {"changes_sap": True,  "leaves_company": False, "reversible": True},
    "request_credit_release": {"changes_sap": True,  "leaves_company": False, "reversible": False},
}
APPROVAL_AMOUNT = 50000.00  # any change on an order above this needs approval, whatever the kind
REJECT_REASONS = ["CUSTOMER_PAYMENT_EXPECTED", "RISK_TOO_HIGH", "DATA_WRONG", "WRONG_ACTION", "OTHER"]
UNDO_REASONS = ["WRONG_CLERK", "NOT_NEEDED", "DATA_WRONG", "OTHER"]


def choose_mode(kind: str, amount: float) -> str:
    """How much a person is involved. Decided in code, never by the model."""
    p = POLICY[kind]
    if not p["changes_sap"]:
        return "draft" if p["leaves_company"] else "recommend"  # a person sends it, or a person decides
    if p["reversible"] and amount <= APPROVAL_AMOUNT:
        return "act_and_report"  # low impact and easy to undo: do it, tell someone, allow undo
    return "act_with_approval"   # changes SAP and is hard to undo, or the order is large


def required_role(order: dict) -> str:
    """Who may approve a credit release request (made-up rule from earlier Unit 9 topics)."""
    over = (order["exposure_after_order"] - order["credit_limit"]) / order["credit_limit"]
    return "CREDIT_MANAGER" if over <= 0.05 else "HEAD_OF_FINANCE"


def triage(order: dict) -> dict:
    """Rule-based stand-in for the triage agent: one proposal per order, with its evidence."""
    o = order["SalesOrder"]
    if order["block"] == "credit":
        over = order["exposure_after_order"] - order["credit_limit"]
        pct = over / order["credit_limit"] * 100
        return {"kind": "request_credit_release", "order": o,
                "args": {"order": o, "amount": order["TotalNetAmount"], "currency": order["TransactionCurrency"]},
                "recommendation": "release", "confidence": "high" if order["open_overdue"] == 0 else "low",
                "evidence": [f"exposure after order {order['exposure_after_order']:,.0f} vs limit "
                             f"{order['credit_limit']:,.0f} ({pct:.1f}% over)",
                             f"overdue items: {order['open_overdue']:,.0f}"]}
    if "postal code" in order.get("note", "") and "owner unclear" not in order["note"]:
        return {"kind": "draft_customer_email", "order": o,
                "args": {"order": o, "to": order["SoldToParty"],
                         "text": "Please confirm the postal code for your delivery address."},
                "recommendation": "send", "confidence": "high", "evidence": [order["note"]]}
    if "owner unclear" in order.get("note", ""):
        return {"kind": "assign_worklist_owner", "order": o, "args": {"order": o, "owner": "CLERK_NORTH"},
                "recommendation": "assign", "confidence": "medium", "evidence": [order["note"]]}
    return {"kind": "recommend_next_check", "order": o,
            "args": {"order": o, "check": "ask the billing team who set the block and why"},
            "recommendation": "check", "confidence": "low", "evidence": [order["note"]]}


# ---------- storage and the audit chain ----------
def now() -> datetime:
    return datetime.now(timezone.utc).replace(microsecond=0)


def load(path: Path, default):
    return json.loads(path.read_text(encoding="utf-8")) if path.exists() else default


def save(path: Path, data) -> None:
    LAB.mkdir(exist_ok=True)
    path.write_text(json.dumps(data, indent=2), encoding="utf-8")


def canonical_hash(obj) -> str:
    return hashlib.sha256(json.dumps(obj, sort_keys=True).encode()).hexdigest()


def audit(event: str, **fields) -> None:
    """Append one entry. Each entry includes the previous entry's hash, so edits break the chain."""
    LAB.mkdir(exist_ok=True)
    prev = "0" * 64
    if AUDIT.exists():
        lines = AUDIT.read_text(encoding="utf-8").splitlines()
        if lines:
            prev = json.loads(lines[-1])["entry_hash"]
    entry = {"at": now().isoformat(), "event": event, **fields, "prev_hash": prev}
    entry["entry_hash"] = canonical_hash(entry)
    with AUDIT.open("a", encoding="utf-8") as f:
        f.write(json.dumps(entry) + "\n")


# ---------- approval tokens ----------
def signing_key() -> bytes:
    key = os.environ.get("APPROVAL_SIGNING_KEY")
    if key:
        return key.encode()
    if not KEYFILE.exists():  # lab convenience: a random key on first use
        LAB.mkdir(exist_ok=True)
        KEYFILE.write_text(secrets.token_hex(32), encoding="utf-8")
    return KEYFILE.read_text(encoding="utf-8").strip().encode()


def issue_token(proposal: dict, approver: str, role: str, ttl_minutes: int) -> str:
    claims = {"pid": proposal["id"], "action_hash": canonical_hash(proposal["args"]), "kind": proposal["kind"],
              "approver": approver, "role": role, "nonce": secrets.token_hex(8),
              "expires": (now() + timedelta(minutes=ttl_minutes)).isoformat()}
    body = base64.urlsafe_b64encode(json.dumps(claims, sort_keys=True).encode()).decode()
    sig = hmac.new(signing_key(), body.encode(), hashlib.sha256).hexdigest()
    return f"{body}.{sig}"


def check_token(token: str, proposal: dict, used: list) -> tuple:
    """Return (ok, reason, claims). Every check is in code; the model never sees the token."""
    try:
        body, sig = token.rsplit(".", 1)
        claims = json.loads(base64.urlsafe_b64decode(body.encode()))
    except (ValueError, json.JSONDecodeError):
        return False, "token is malformed", {}
    good = hmac.new(signing_key(), body.encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, good):
        return False, "signature does not match (token forged or key changed)", claims
    if claims["pid"] != proposal["id"]:
        return False, "token belongs to another proposal", claims
    if claims["kind"] != proposal["kind"]:
        return False, "the action changed after it was approved", claims
    if claims["action_hash"] != canonical_hash(proposal["args"]):
        return False, "the action changed after it was approved", claims
    if now() >= datetime.fromisoformat(claims["expires"]):
        return False, "approval has expired; ask for a new decision", claims
    if claims["nonce"] in used:
        return False, "token was already used", claims
    return True, "ok", claims


# ---------- commands ----------
def cmd_reset(_):
    for p in (QUEUE, SAP, AUDIT, SIGNAL, KEYFILE):
        if p.exists():
            p.unlink()
    print("Lab files deleted.")


def cmd_propose(args):
    if not args.sample:
        sys.exit("This lab runs on made-up orders: add --sample.")
    queue, sap = load(QUEUE, []), load(SAP, {"changes": [], "used_tokens": []})
    if queue:
        sys.exit("Proposals already exist. Run 'reset' first to start again.")
    for i, order in enumerate(SAMPLE_ORDERS, start=1):
        prop = triage(order)
        prop.update(id=f"P{i}", requested_by=REQUESTER, created=now().isoformat(),
                    mode=choose_mode(prop["kind"], order["TotalNetAmount"]),
                    role=required_role(order) if prop["kind"] == "request_credit_release" else "CLERK",
                    status="pending")
        if prop["mode"] == "act_and_report":
            apply_change(sap, prop, approver="policy:act_and_report")
            prop["status"] = "done_reported"
        queue.append(prop)
        audit("proposed", pid=prop["id"], order=prop["order"], kind=prop["kind"], mode=prop["mode"],
              action_hash=canonical_hash(prop["args"]))
    save(QUEUE, queue)
    save(SAP, sap)
    for p in queue:
        print(f"{p['id']}  order {p['order']}  {p['kind']:<23} mode={p['mode']:<18} status={p['status']}")
    print("Next: python unit09/approval_gate.py inbox")


def cmd_inbox(_):
    queue = load(QUEUE, [])
    waiting = [p for p in queue if p["status"] in ("pending", "done_reported")]
    if not waiting:
        print("Inbox is empty: nothing waits for a person.")
        return
    for p in waiting:
        head = "DONE, tell us if wrong" if p["status"] == "done_reported" else f"needs {p['role']}"
        print(f"[{p['id']}] order {p['order']}  {p['kind']}  ({p['mode']}, {head})")
        print(f"     AI suggests: {p['recommendation']}  confidence: {p['confidence']}")
        for e in p["evidence"]:
            print(f"     evidence: {e}")
        print(f"     exact action: {json.dumps(p['args'])}")
    print("Reject reasons:", ", ".join(REJECT_REASONS))


def cmd_decide(args):
    queue = load(QUEUE, [])
    p = next((x for x in queue if x["id"] == args.id), None)
    if p is None:
        sys.exit(f"No proposal {args.id}.")
    if p["status"] != "pending":
        sys.exit(f"{p['id']} is {p['status']}; only pending proposals take a decision.")
    if args.by.strip().lower() == REQUESTER:
        sys.exit("Refused: the requester can't approve its own proposal.")
    if p["mode"] == "act_with_approval" and args.role != p["role"]:
        sys.exit(f"Refused: {p['id']} needs role {p['role']}, you acted as {args.role}.")
    if args.reject and args.reason not in REJECT_REASONS:
        sys.exit(f"A rejection needs --reason, one of: {', '.join(REJECT_REASONS)}")
    decision = "accept" if args.accept else "reject"
    p["decision"] = {"by": args.by, "role": args.role, "decision": decision, "reason": args.reason,
                     "comment": args.comment, "at": now().isoformat()}
    if decision == "accept" and p["mode"] == "act_with_approval":
        p["token"] = issue_token(p, args.by, args.role, args.ttl_minutes)
        p["status"] = "approved"
    elif decision == "accept":
        p["status"] = "accepted"  # draft or recommend: the person carries it out themselves
    else:
        p["status"] = "rejected"
    if decision != "accept":
        log_override(p, kind="rejected", reason=args.reason, comment=args.comment)
    audit("decided", pid=p["id"], by=args.by, role=args.role, decision=decision, reason=args.reason,
          action_hash=canonical_hash(p["args"]))
    save(QUEUE, queue)
    print(f"{p['id']}: {decision} by {args.by} ({args.role}). Status: {p['status']}.")
    if p["status"] == "approved":
        print(f"Approval token issued, valid {args.ttl_minutes} minutes. Next: python unit09/approval_gate.py execute")


def apply_change(sap: dict, p: dict, approver: str) -> str:
    """Stands in for the SAP call. The idempotency key makes a repeat do nothing."""
    key = f"{p['id']}:{canonical_hash(p['args'])[:12]}"
    if any(c["key"] == key for c in sap["changes"]):
        return "already done (same idempotency key)"
    sap["changes"].append({"key": key, "kind": p["kind"], "args": p["args"], "approved_by": approver,
                           "at": now().isoformat()})
    return "done"


def cmd_execute(_):
    queue, sap = load(QUEUE, []), load(SAP, {"changes": [], "used_tokens": []})
    todo = [p for p in queue if p["status"] == "approved"]
    if not todo:
        print("Nothing approved is waiting to run.")
    for p in todo:
        ok, why, claims = check_token(p.get("token", ""), p, sap["used_tokens"])
        if not ok:
            audit("refused", pid=p["id"], reason=why, action_hash=canonical_hash(p["args"]))
            if why.startswith("approval has expired"):
                p["status"] = "pending"  # back to the inbox for a fresh decision
                p.pop("token", None)
                print(f"{p['id']}: REFUSED, {why}. It is back in the inbox.")
            else:
                p["status"] = "refused"
                print(f"{p['id']}: REFUSED, {why}.")
            continue
        result = apply_change(sap, p, approver=f"{claims['approver']} ({claims['role']})")
        sap["used_tokens"].append(claims["nonce"])
        p["status"] = "executed"
        audit("executed", pid=p["id"], approver=claims["approver"], role=claims["role"], result=result,
              action_hash=claims["action_hash"])
        print(f"{p['id']}: executed for order {p['order']}, approved by {claims['approver']} ({claims['role']}).")
    save(QUEUE, queue)
    save(SAP, sap)


def cmd_tamper(args):
    queue = load(QUEUE, [])
    p = next((x for x in queue if x["id"] == args.id), None)
    if p is None or "amount" not in p["args"]:
        sys.exit("Pick a credit release proposal, such as P1.")
    p["args"]["amount"] = round(p["args"]["amount"] * 3, 2)
    if p["status"] == "executed":
        p["status"] = "approved"  # pretend a bug or an attacker queues it again
    save(QUEUE, queue)
    print(f"{p['id']} now asks for {p['args']['amount']:,.2f}. The approval was for the old amount.")


def cmd_undo(args):
    queue, sap = load(QUEUE, []), load(SAP, {"changes": [], "used_tokens": []})
    p = next((x for x in queue if x["id"] == args.id), None)
    if p is None or p["status"] != "done_reported":
        sys.exit("Only act-and-report actions can be undone here.")
    if args.reason not in UNDO_REASONS:
        sys.exit(f"An undo needs --reason, one of: {', '.join(UNDO_REASONS)}")
    sap["changes"] = [c for c in sap["changes"] if not c["key"].startswith(p["id"] + ":")]
    p["status"] = "undone"
    p["decision"] = {"by": args.by, "decision": "undo", "reason": args.reason, "comment": args.comment,
                     "at": now().isoformat()}
    log_override(p, kind="undone", reason=args.reason, comment=args.comment)
    audit("undone", pid=p["id"], by=args.by, reason=args.reason)
    save(QUEUE, queue)
    save(SAP, sap)
    print(f"{p['id']}: undone by {args.by} ({args.reason}).")


def log_override(p: dict, kind: str, reason: str, comment: str) -> None:
    """One line per disagreement: what the AI saw and said, what the person did, and why."""
    LAB.mkdir(exist_ok=True)
    row = {"pid": p["id"], "order": p["order"], "kind": p["kind"], "ai_recommendation": p["recommendation"],
           "ai_confidence": p["confidence"], "evidence": p["evidence"], "human": kind, "reason": reason,
           "comment": comment, "at": now().isoformat()}
    with SIGNAL.open("a", encoding="utf-8") as f:
        f.write(json.dumps(row) + "\n")


def cmd_audit(_):
    if not AUDIT.exists():
        sys.exit("No audit log yet. Run 'propose --sample' first.")
    prev, ok = "0" * 64, True
    for n, line in enumerate(AUDIT.read_text(encoding="utf-8").splitlines(), start=1):
        e = json.loads(line)
        body = {k: v for k, v in e.items() if k != "entry_hash"}
        if e["prev_hash"] != prev or canonical_hash(body) != e["entry_hash"]:
            print(f"line {n}: CHAIN BROKEN (edited, removed or reordered)")
            ok = False
        prev = e["entry_hash"]
        extra = {k: v for k, v in e.items() if k not in ("at", "event", "prev_hash", "entry_hash", "action_hash")}
        print(f"{n:>2} {e['at']} {e['event']:<9} {json.dumps(extra)}")
    print("Audit chain intact." if ok else "Audit chain BROKEN: investigate before trusting this log.")


def cmd_report(_):
    queue = load(QUEUE, [])
    decided = [p for p in queue if p.get("decision")]
    if not decided:
        print("No decisions yet.")
        return
    by_kind = {}
    for p in decided:
        k = by_kind.setdefault(p["kind"], {"decided": 0, "overridden": 0})
        k["decided"] += 1
        k["overridden"] += p["decision"]["decision"] != "accept"
    print(f"{'action kind':<24}{'decided':>8}{'overridden':>12}{'override rate':>15}")
    for kind, k in by_kind.items():
        print(f"{kind:<24}{k['decided']:>8}{k['overridden']:>12}{k['overridden'] / k['decided']:>14.0%}")
    rows = [json.loads(x) for x in SIGNAL.read_text(encoding="utf-8").splitlines()] if SIGNAL.exists() else []
    reasons = {}
    for r in rows:
        reasons[r["reason"]] = reasons.get(r["reason"], 0) + 1
    print("Override reasons:", ", ".join(f"{k} x{v}" for k, v in reasons.items()) or "none")
    waiting = [p["id"] for p in queue if p["status"] in ("pending", "approved")]
    print("Still waiting:", ", ".join(waiting) or "nothing")


def main():
    ap = argparse.ArgumentParser(description="Approval gate for the blocked-orders triage (Unit 9 lab).")
    sub = ap.add_subparsers(dest="cmd", required=True)
    sub.add_parser("reset")
    p = sub.add_parser("propose")
    p.add_argument("--sample", action="store_true", help="use the five made-up orders")
    sub.add_parser("inbox")
    d = sub.add_parser("decide")
    d.add_argument("--id", required=True)
    g = d.add_mutually_exclusive_group(required=True)
    g.add_argument("--accept", action="store_true")
    g.add_argument("--reject", action="store_true")
    d.add_argument("--by", required=True, help="the person deciding")
    d.add_argument("--role", default="CLERK", help="lab shortcut; in production this comes from the login")
    d.add_argument("--reason", default=None)
    d.add_argument("--comment", default="")
    d.add_argument("--ttl-minutes", type=int, default=480, help="how long an approval stays valid")
    sub.add_parser("execute")
    t = sub.add_parser("tamper")
    t.add_argument("--id", required=True)
    u = sub.add_parser("undo")
    u.add_argument("--id", required=True)
    u.add_argument("--reason", required=True)
    u.add_argument("--by", required=True)
    u.add_argument("--comment", default="")
    sub.add_parser("audit")
    sub.add_parser("report")
    args = ap.parse_args()
    {"reset": cmd_reset, "propose": cmd_propose, "inbox": cmd_inbox, "decide": cmd_decide,
     "execute": cmd_execute, "tamper": cmd_tamper, "undo": cmd_undo, "audit": cmd_audit,
     "report": cmd_report}[args.cmd](args)


if __name__ == "__main__":
    main()

Step 3 (optional): Set your own signing key

You can skip this step; the lab then makes a random key for you. To practise the production habit, add a key to .env.

  1. Make a random key (same command on every system):

    python -c "import secrets; print(secrets.token_hex(32))"
  2. Open .env in VS Code and add one line, pasting your key after the =:

    APPROVAL_SIGNING_KEY=paste-the-64-characters-here
  3. Save .env. Don't change the key during the lab: tokens signed with the old key will be refused, which is the point of the signature.

Step 4: Let the triage agent propose

  1. Run (same command on every system):

    python unit09/approval_gate.py reset
    python unit09/approval_gate.py propose --sample
  2. You should see:

    Lab files deleted.
    P1  order 4711  request_credit_release  mode=act_with_approval  status=pending
    P2  order 4712  request_credit_release  mode=act_with_approval  status=pending
    P3  order 4713  draft_customer_email    mode=draft              status=pending
    P4  order 4714  recommend_next_check    mode=recommend          status=pending
    P5  order 4715  assign_worklist_owner   mode=act_and_report     status=done_reported
    Next: python unit09/approval_gate.py inbox

Each order got a different pattern from the same policy table. P5 has already happened: assigning a worklist owner is internal and reversible, and the order is small. The credit releases wait for a person because a release request isn't easy to take back.

Step 5: Read the inbox

  1. Run:

    python unit09/approval_gate.py inbox
  2. You should see each item with its evidence and the exact action. The first two look like this:

    [P1] order 4711  request_credit_release  (act_with_approval, needs CREDIT_MANAGER)
         AI suggests: release  confidence: high
         evidence: exposure after order 412,000 vs limit 400,000 (3.0% over)
         evidence: overdue items: 0
         exact action: {"order": "4711", "amount": 41200.0, "currency": "EUR"}
    [P2] order 4712  request_credit_release  (act_with_approval, needs HEAD_OF_FINANCE)
         AI suggests: release  confidence: low
         evidence: exposure after order 295,000 vs limit 250,000 (18.0% over)
         evidence: overdue items: 20,000

The required role came from code: 5% or less over the limit needs CREDIT_MANAGER, more needs HEAD_OF_FINANCE. That is a made-up rule carried over from earlier Unit 9 topics. P5 shows as DONE, tell us if wrong, which is how act-and-report should look. An empty inbox prints Inbox is empty: nothing waits for a person.

Step 6: Decide, with the wrong role first

  1. Try to approve P1 as a clerk:

    python unit09/approval_gate.py decide --id P1 --accept --by "Kim Lee" --role CLERK
    Refused: P1 needs role CREDIT_MANAGER, you acted as CLERK.
  2. Approve with the right role:

    python unit09/approval_gate.py decide --id P1 --accept --by "Kim Lee" --role CREDIT_MANAGER
    P1: accept by Kim Lee (CREDIT_MANAGER). Status: approved.
    Approval token issued, valid 480 minutes. Next: python unit09/approval_gate.py execute
  3. Reject P2 with a reason. On Windows PowerShell, put the whole command on one line; on macOS/Linux the \ lets it continue:

    • Windows (PowerShell):

      python unit09/approval_gate.py decide --id P2 --reject --reason CUSTOMER_PAYMENT_EXPECTED --by "Ana Diaz" --role HEAD_OF_FINANCE --comment "Payment of 20,000 promised for Friday"
    • macOS / Linux:

      python unit09/approval_gate.py decide --id P2 --reject --reason CUSTOMER_PAYMENT_EXPECTED \
        --by "Ana Diaz" --role HEAD_OF_FINANCE --comment "Payment of 20,000 promised for Friday"
  4. Accept the draft email and reject the recommendation (same commands on every system):

    python unit09/approval_gate.py decide --id P3 --accept --by "Kim Lee"
    python unit09/approval_gate.py decide --id P4 --reject --reason WRONG_ACTION --by "Kim Lee" --comment "Manual billing blocks come from the contracts team"
    P3: accept by Kim Lee (CLERK). Status: accepted.
    P4: reject by Kim Lee (CLERK). Status: rejected.

Accepting P3 issues no token: a draft changes nothing in SAP, and the clerk sends the email from their own mail tool. If you leave out --reason on a rejection, the script stops and lists the allowed reasons. Try --by triage-agent too: the requester can never approve its own proposal.

Step 7: Execute, then try a replay

  1. Run the separate execute step twice:

    python unit09/approval_gate.py execute
    python unit09/approval_gate.py execute
    P1: executed for order 4711, approved by Kim Lee (CREDIT_MANAGER).
    Nothing approved is waiting to run.

Only P1 ran. P2 was rejected, and P3 and P4 are for people to carry out. Running execute again did nothing. Open unit09/approval_lab/sap_stub.json: it holds one change, with the idempotency key and the approver, and the token's nonce under used_tokens.

Step 8: Tamper with an approved action

  1. Pretend a bug or an attacker triples the amount after approval and queues it again:

    python unit09/approval_gate.py tamper --id P1
    python unit09/approval_gate.py execute
    P1 now asks for 123,600.00. The approval was for the old amount.
    P1: REFUSED, the action changed after it was approved.

The token covers the hash of the exact arguments, and check_token also compares the action kind it was issued for. Any change, even one digit, breaks the match. Kim approved 41,200, so nothing else can run under her name.

Step 9: Undo an act-and-report action, then read the report

  1. Kim notices that P5 went to the wrong clerk:

    python unit09/approval_gate.py undo --id P5 --reason WRONG_CLERK --by "Kim Lee" --comment "North clerk is on leave"
    python unit09/approval_gate.py report
    P5: undone by Kim Lee (WRONG_CLERK).
    action kind              decided  overridden  override rate
    request_credit_release         2           1           50%
    draft_customer_email           1           0            0%
    recommend_next_check           1           1          100%
    assign_worklist_owner          1           1          100%
    Override reasons: CUSTOMER_PAYMENT_EXPECTED x1, WRONG_ACTION x1, WRONG_CLERK x1
    Still waiting: nothing
  2. Open unit09/approval_lab/override_signal.jsonl. Each line pairs what the AI saw and proposed with what the person did and why. The first line reads, in part, "ai_recommendation": "release", "ai_confidence": "low", ... "reason": "CUSTOMER_PAYMENT_EXPECTED".

Five decisions are too few for real rates; on a real worklist you'd read this weekly. Notice what it already tells you: the triage agent doesn't look at promised payments, and it doesn't know who sets manual billing blocks. Those are two specific fixes.

Step 10: Check the audit trail, then break it

  1. Run:

    python unit09/approval_gate.py audit

    The end of the output looks like this (your times differ):

    10 2026-10-17T02:25:51+00:00 executed  {"pid": "P1", "approver": "Kim Lee", "role": "CREDIT_MANAGER", "result": "done"}
    11 2026-10-17T02:25:51+00:00 refused   {"pid": "P1", "reason": "the action changed after it was approved"}
    12 2026-10-17T02:25:51+00:00 undone    {"pid": "P5", "by": "Kim Lee", "reason": "WRONG_CLERK"}
    Audit chain intact.
  2. Open unit09/approval_lab/audit.jsonl in VS Code. On line 7, change "Ana Diaz" to "Kim Lee" and save.

  3. Run python unit09/approval_gate.py audit again. You should see line 7: CHAIN BROKEN (edited, removed or reordered) and, at the end, Audit chain BROKEN: investigate before trusting this log.

Step 11: Save your work in Git

  1. Check what Git sees:

    git status

    You should see unit09/approval_gate.py and the unit09/approval_lab/ folder. You must not see .env.

  2. Keep the lab key out of Git, then commit the script:

    • Windows (PowerShell):

      Add-Content .gitignore "unit09/approval_lab/lab_signing_key.txt"
    • macOS / Linux:

      echo "unit09/approval_lab/lab_signing_key.txt" >> .gitignore
    git add .gitignore unit09/approval_gate.py
    git commit -m "Unit 9: approval gate with tokens, execute step, audit chain and override log"

What each part of the script does

Part What it does
SAMPLE_ORDERS Five made-up blocked orders with fields shaped like the Sales Order API, plus credit facts
POLICY, APPROVAL_AMOUNT The policy table: does the action change SAP, leave the company, can it be undone; plus the amount line
choose_mode Turns the policy answers into one of the four patterns. The model never sets this
required_role Picks the approver role from the credit facts (lab rule)
triage Stand-in for the triage agent: one proposal per order, with evidence and confidence
audit Appends a hash-chained entry for every proposal, decision, execution, refusal and undo
issue_token, check_token Sign an approval for one exact action; check signature, hash, expiry and reuse before execution
cmd_decide Records a person's decision; refuses the requester and the wrong role; requires a reason on reject
apply_change Stands in for the SAP call, with an idempotency key
cmd_execute The only path to a change; runs approved items whose token passes every check
cmd_undo, log_override Reverse an act-and-report action and record every disagreement with its reason
cmd_report Override rate by action kind and the count of each reason

If something goes wrong

What you see What it means What to do
python is not recognized, or command not found Python isn't on your path, or the terminal opened before you installed it Close and reopen VS Code; see Set up your computer
can't open file ... approval_gate.py The terminal isn't in your course folder, or the file has another name Open orchestrate-course in VS Code (Step 1) and check the file is unit09/approval_gate.py
ModuleNotFoundError for any module A typo when pasting, or an old Python The script uses built-in modules only; paste it again, and check python --version shows 3.10 or later
Proposals already exist You ran propose twice Run reset, then propose --sample
Refused: P1 needs role ... The role doesn't match the one the policy set Use the role the inbox shows; that check is the point
REFUSED, signature does not match The signing key changed since the approval Keep one key for the lab; run reset and start again
REFUSED, approval has expired The token's time ran out The item is back in the inbox; decide it again
Activate.ps1 cannot be loaded because running scripts is disabled PowerShell blocks scripts by default Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned once, then try again
A network or proxy error Not expected: this lab never uses the network Check you are running approval_gate.py, not another script

The SAP way

As of October 2026, an AI proposal in an SAP landscape should reach a person through the task and approval tools SAP already provides, and the change should happen through the process's own decision step.

Credit blocks already have a decision step

SAP's credit management training describes the flow: an automatic credit check blocks the order, the system assigns a credit case to an analyst, and the analyst works it in Manage Documented Credit Decisions, choosing Release, Recheck or Reject, with a reason and a note for a rejection.

That shapes the design. The lab's request_credit_release becomes "prepare the case": the triage agent writes the evidence and a proposal that the analyst sees next to the documented credit decision. The release itself stays the analyst's act in SAP. The SAP rejection reason is a natural override signal, so map your reason codes to the ones the process already uses rather than inventing a parallel list. Which credit APIs your system exposes for reading cases, and whether any write is allowed, is something to confirm on the SAP Business Accelerator Hub for your release before you design further; SAP tools for agents covers wrapping such APIs.

Workflow tasks in My Inbox

For actions without a built-in decision step, a workflow task is the usual route.

  • SAP S/4HANA flexible workflows send approval tasks to My Inbox. Approvers can approve, reject, claim, suspend or forward, name a substitute with Manage My Substitutes, and see completed tasks with their task and workflow logs in My Outbox. The recipient needs the authorization for the task; SAP's training names the business catalogs to add when a role is too broad.
  • SAP Build Process Automation builds custom approvals in a low-code process builder. An Approval form becomes a task in the approver's My Inbox, with a subject built from process data and a due date, and the process branches on Approve or Reject. That is where the lab's inbox and execute step would live for actions on BTP: the AI's proposal starts the process, the form shows the evidence and exact action, and only the Approve branch calls the API that changes data.
  • SAP Task Center gives approvers one list across providers, including SAP S/4HANA and SAP Build Process Automation approval use cases. SAP notes that not every provider use case is supported and that some actions open the native app, so check that yours works there before promising "one inbox".

Joule agents

SAP's Joule Studio course describes a Human in the Loop tool that pauses a Joule agent's workflow and requests approval or input, with the example of an approval task for the department manager when a request is unusually large. Before relying on it, ask what is recorded (who, which role, which exact action) and whether the approval is tied to the action that runs, the same questions the lab answers with its token. Joule Studio's availability and pricing were changing in October 2026; Joule and Joule Studio has the details we found.

Licensing

The sources we opened don't cover licensing for SAP Build Process Automation, SAP Task Center or Joule Studio, and plans change. Check current terms with SAP or your partner before you design around a service; this topic makes no claim about prices.

Build vs. SAP

Need Build it yourself SAP
Pattern per action A policy table in your code, reviewed with the business The same table, implemented as process routing in SAP Build Process Automation
Approver inbox Only for a prototype; people won't adopt a new inbox My Inbox or SAP Task Center
Approval tied to the exact action Token with hash, expiry and nonce, as in the lab Check what the workflow task stores; pass the action hash as process data
Separate execute step A service the model can't call The Approve branch of a workflow, calling the API under a technical or user identity
Decision on a credit block Don't rebuild it Manage Documented Credit Decisions; the AI prepares the case
Audit trail Hash-chained log in write-once storage Workflow and task logs; add your own record of the AI proposal and evidence
Override signal Reason codes and a log you analyse Rejection reasons and comments from the workflow; export them for analysis

Rule of thumb: build the policy, the proposal and the evidence; use SAP for the inbox, the approval and the change.

Production concerns

  • Identity and authorizations. The approver's identity and role come from the login and SAP role assignments, never from a --role flag or anything the model writes. Agent permissions and SAP authorizations covers segregation of duties: the requester can't approve.
  • Key management. The signing key lives in a secret store, rotates on a schedule, and is readable only by the decide and execute services. Rotating it invalidates pending tokens; plan for that.
  • Freshness. Expiry is a business setting. A credit approval made at 9:00 on stale exposure data should not run at 17:00. Short expiry plus a re-check of key facts at execute time is safer than a long expiry.
  • Approval fatigue. Monitor approvals per person per day, time-to-decide and the approve-without-change rate. Sample approved items for a second review. Move low-risk kinds to act-and-report rather than training people to click.
  • Overrides as data. Review override reasons weekly with the process owner. Turn repeated reasons into fixes and into test cases for your evaluation harness. Comments are free text written by people; treat them as data in any later prompt, never as instructions.
  • Stop control. Provide a switch that stops proposals and execution at once, and test it. The EU AI Act's oversight article asks for a way to bring high-risk systems to a halt in a safe state; The EU AI Act for SAP customers covers when that applies.
  • Audit storage. A hash chain only detects edits by someone who can't recompute it. Write audit entries to storage the application can append to but not rewrite, and keep them as long as the business records they explain.
  • Clean core. Proposals, evidence and the approval flow live on BTP; changes go through released APIs or the process's own decision app. Nothing here needs custom code inside SAP S/4HANA.

Pitfalls

  • Approving a description. The person reads "release order 4711" and the code sends something else. Hash the exact arguments and show them.
  • Letting the model choose the mode. "This seems low risk, I'll just do it" is the failure you are designing against. The mode comes from the policy table.
  • One pattern for everything. Approval on every action trains people to click; none on any action is excessive agency.
  • Side effects before the pause. In frameworks that re-run a step on resume, a call placed before the interrupt happens twice. Put changes in the execute step, with an idempotency key.
  • Free-text-only overrides. Comments are hard to count. Require a reason code; make the comment optional.
  • A second approval path. Building an AI approval next to an existing SAP decision step creates two truths. Feed the existing step instead.
  • Tokens that never expire. The data moves; the approval doesn't. Set an expiry and re-check key facts at execution.

Exercise

You'll move the amount line, see a pattern change, test an expired approval, and write a one-page approval design. The design note feeds Event-driven agents, where proposals arrive from SAP business events instead of a batch.

  1. Run python unit09/approval_gate.py reset.

  2. Open unit09/approval_gate.py, find APPROVAL_AMOUNT = 50000.00, change it to APPROVAL_AMOUNT = 500.00 and save.

  3. Run python unit09/approval_gate.py propose --sample. P5 should now show mode=act_with_approval, because the 950 order is above the new line.

  4. Change the line back to 50000.00 and save. Run reset and propose --sample again.

  5. Approve P1 with an approval that expires at once:

    python unit09/approval_gate.py decide --id P1 --accept --by "Kim Lee" --role CREDIT_MANAGER --ttl-minutes 0
    python unit09/approval_gate.py execute

    You should see P1: REFUSED, approval has expired; ask for a new decision. It is back in the inbox.

  6. Approve P1 again without --ttl-minutes and run execute. It should run.

  7. Create unit09/approval_design.md with a table of five columns: action kind, pattern, approver role, evidence shown, override reasons. Fill one row for each of the four action kinds in POLICY, and add one row for an action from your own process (for example, changing a requested delivery date).

  8. Under the table, write two sentences: which existing SAP decision step each credit action should feed, and which inbox your approvers use today.

  9. Commit: git add unit09/approval_design.md && git commit -m "Unit 9: approval design note".

Done when: step 5 prints the expired refusal, step 6 executes P1, and approval_design.md has five rows with a pattern and approver role in each.

Going further

For readers who want the research behind the four patterns and the human factors that make approvals work or fail.

  • Levels of automation. Parasuraman, Sheridan and Wickens (2000) proposed splitting automation into four functions (information acquisition, information analysis, decision and action selection, action implementation) and setting a level for each. Their point that one system can mix levels across functions is why the lab's agent can fully automate evidence gathering while leaving action implementation to a person. They also argue that human performance should be the first criterion when choosing a level, with automation reliability and the cost of a wrong action next. IEEE SMC-A record
  • Automation bias. Goddard, Roudsari and Wyatt's systematic review (74 studies, mostly healthcare) found that workload, task complexity and time pressure increase over-reliance on decision support. Mitigations included training, stressing users' accountability, showing updated confidence levels, and presenting information rather than direct recommendations. That is the evidence behind showing facts before the suggestion in your inbox design. JAMIA abstract
  • Oversight as a legal requirement. Article 14 of the EU AI Act asks that oversight measures match the risks, autonomy and context of use, and that overseers can understand the system's limits, stay aware of automation bias, interpret the output, override or reverse it, and stop the system. Read it as a checklist for high-risk use cases even where it doesn't apply to you. AI Act Service Desk
  • Interrupt-and-resume semantics. LangGraph's human-in-the-loop guide shows a framework's version of the pause: interrupt() with a checkpointer and a thread ID, resumed with Command(resume=...). Its warning that the node re-runs from the start on resume is the clearest statement of why execution belongs in its own step. LangGraph docs

Check yourself

Pick one answer for each question. The explanation appears after you choose.

1In the lab, who or what decides that a credit release request needs approval?

Answer: C. choose_mode reads whether the action changes SAP, leaves the company and can be undone, plus the amount. The model never sets the mode, so a model error or injected text can't downgrade an approval to act-and-report.

2What does the approval token's action hash protect against?

Answer: C. The hash covers the exact arguments the approver saw. In Step 8 the tripled amount no longer matched, so execute refused. A replay is caught by the nonce, and a rotated key by the signature check.

3Why does the lab keep execute separate from decide, instead of running the change when the person clicks accept?

Answer: C. The model can be given a propose tool but never an execute tool, so the only route to a change checks the token. A separate step with an idempotency key also avoids the duplicate side effects LangGraph warns about when a node re-runs on resume.

4Your LangGraph node calls the SAP API to create a release request, then calls interrupt() for approval. What goes wrong?

Answer: C. The change happens before the person sees it, which defeats the approval. LangGraph's docs also say the whole node re-runs from its start on resume, so the call repeats. Put the change after approval, in its own step with an idempotency key.

5In an SAP S/4HANA system, the triage agent finds a credit-blocked order that looks safe to release. What is the best design?

Answer: C. Credit blocks already go to an analyst in Manage Documented Credit Decisions, with Release, Recheck and Reject. Feeding that step avoids a second, competing approval path, and its rejection reasons become your override signal.

6A team edits a line in audit.jsonl and also recomputes every hash after it. Why does the chain no longer prove anything?

Answer: B. A hash chain detects edits only by someone who can't recompute it. In production, write audit entries to storage the application can append to but not rewrite.

7Credit managers approve 98% of proposals in under five seconds. What would you do first?

Answer: C. Fast, near-total approval can mean automation bias rather than a perfect agent. Sampling approvals and watching time-to-decide shows whether people are really checking, before you change the pattern.

8What makes the override log useful as a training signal?

Answer: C. Each row shows what the AI saw and proposed, what the person did and why, so reasons can be counted by action kind. Comments are free text and must stay data, never instructions for the next run.

Practice this in the lab

Orchestrate Lab is a free practice ERP in your browser, built around the fictional Calder Pumpworks. These are the places in it where you do what this topic teaches.

Stages build on each other: a stage link opens the core path at that stage. To start there without doing the earlier stages yourself, use Do this stage for me on the stage before it. A chapter starts inside your own company once the core path is done (or the lab finishes the core for you first); drills and exercises start from their own prepared copy. Everything is saved in this browser only.

Sources

Sign in to track your progress

We'll email you a one-time sign-in link. No password needed.

or

Tell us a little about you

Optional, every field. It helps us pitch answers to your questions at the right level and decide which topics to write next. It is never shown publicly, and you can change or clear it anytime from the account menu.

SAP areas you work in