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.
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:
Draft. The AI writes something; a person edits and sends it. Nothing happens unless the person acts.
Recommend. The AI suggests a decision with its evidence; a person decides and acts.
Act with approval. The AI prepares the exact change; it runs only after a person with the right role approves that exact change.
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.
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.
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.
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.
"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.
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.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
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.
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:
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.
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.
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.
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
In VS Code's file list, right-click unit09, choose New File and name it approval_gate.py.
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()
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.
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.
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.
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.
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
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
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.
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.
Open unit09/approval_lab/audit.jsonl in VS Code. On line 7, change "Ana Diaz" to "Kim Lee" and save.
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.
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.
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.
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".
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.
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.
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.
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.
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.
Run python unit09/approval_gate.py reset.
Open unit09/approval_gate.py, find APPROVAL_AMOUNT = 50000.00, change it to APPROVAL_AMOUNT = 500.00 and save.
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.
Change the line back to 50000.00 and save. Run reset and propose --sample again.
You should see P1: REFUSED, approval has expired; ask for a new decision. It is back in the inbox.
Approve P1 again without --ttl-minutes and run execute. It should run.
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).
Under the table, write two sentences: which existing SAP decision step each credit action should feed, and which inbox your approvers use today.
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
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.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
Related topics
Picked from the terms, links and tags this topic shares with others.
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.
AI stopTaking ordersAt the end of stage 8, "Take orders".
AI stopPaying and closingAt the end of stage 11, "Pay and close the month".
AI stopDemand planningAt the end of stage 11, "Pay and close the month".
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.
Centralizing tasks with SAP Task Center (SAP Learning)— single entry point for tasks from SAP and non-SAP applications; providers include SAP S/4HANA and SAP Build Process Automation approval use cases; not all provider use cases supported; navigate to the native app for the full set of actions
LLM06:2025 Excessive Agency (OWASP GenAI Security Project)— require a human to approve high-impact actions; implement authorization in downstream systems rather than relying on the LLM; execute in the user's context; logging limits damage but doesn't prevent
Add human-in-the-loop (LangGraph documentation)— interrupt() pauses for review; resume with Command(resume=...); a checkpointer and thread_id are required; the whole node re-runs on resume, so side effects before the interrupt repeat