An operator reviews a proposed refund for order-17. Before execution, an agent changes the target to order-18. A boolean approved flag still says yes. The action that reaches the provider is now different from the one the operator reviewed.
The useful control is a server-side approval record bound to the material action fields. The following Python fixture makes that decision independently testable. It uses synthetic orders and makes no network calls.
Define the object that approval covers
Choose a small explicit schema. This example binds a tenant, operation, target order, integer amount in minor units, currency and request identity. Unknown fields are rejected, so a new execution parameter cannot silently escape the comparison.
Only USD refunds are supported by this example. That is a deliberately narrow fixture scope, not a universal currency or financial policy. A real adapter needs its own schema, provider validation, account permissions and business rules.
The digest below is a content fingerprint. It is not an authentication token, digital signature or proof that an operator approved anything. The approval record must be created by a trusted server after the real review flow and stored where an agent or browser cannot replace it.
Keep the decision outside model output
The predicate checks the current tenant, expiry, the reviewer's current permission and the action fingerprint. It reads identity and approval state from trusted server-side inputs. Never expose this function as an endpoint that accepts a caller-supplied Approval object or a caller-selected permission result.
OWASP's excessive agency guidance separates excessive functionality, permissions and autonomy. A prompt asking the model to be careful cannot replace an application check on the action that will actually execute.
from dataclasses import dataclass
from hashlib import sha256
import json
import unittest
FIELDS = {"tenant", "operation", "target", "amount_minor", "currency", "request_id"}
def fingerprint(action):
if not isinstance(action, dict) or set(action) != FIELDS:
raise ValueError("Unexpected action shape")
if action["operation"] != "refund" or action["currency"] != "USD":
raise ValueError("Unsupported action")
if type(action["amount_minor"]) is not int or action["amount_minor"] <= 0:
raise ValueError("Use a positive integer amount in minor units")
for name in ("tenant", "target", "request_id"):
if not isinstance(action[name], str) or not action[name].strip():
raise ValueError("Missing action identity")
encoded = json.dumps(action, sort_keys=True, separators=(",", ":"),
ensure_ascii=True, allow_nan=False).encode("utf-8")
return sha256(encoded).hexdigest()
@dataclass(frozen=True)
class Approval:
tenant: str
reviewer: str
action_digest: str
expires_at: int
def permits(approval, action, *, current_tenant, now, reviewer_allowed):
# Approval and identity inputs must come from trusted server-side systems.
if not isinstance(approval, Approval) or type(now) is not int:
return False
if now >= approval.expires_at or current_tenant != approval.tenant:
return False
if not reviewer_allowed(approval.reviewer, current_tenant):
return False
try:
return (action["tenant"] == current_tenant
and fingerprint(action) == approval.action_digest)
except (KeyError, TypeError, ValueError):
return False
The expiry rule is explicit: approval is invalid at the expiry instant. Amounts use integers, and Python booleans are rejected even though bool is an int subclass. Reordering dictionary keys preserves the fingerprint because serialization sorts them. Changing a material value changes the decision.
Run the mutation cases
Save both code blocks in one file, with the following tests beneath the predicate, then run python approval_fixture.py -v. The standard-library fixture passed all nine checks in the local run prepared for this article.
class ApprovalTests(unittest.TestCase):
def setUp(self):
self.action = dict(tenant="tenant-a", operation="refund", target="order-17",
amount_minor=2500, currency="USD", request_id="req-9")
self.approval = Approval("tenant-a", "reviewer-2", fingerprint(self.action), 200)
def allowed(self, action, **overrides):
args = dict(current_tenant="tenant-a", now=100,
reviewer_allowed=lambda reviewer, tenant: True)
args.update(overrides)
return permits(self.approval, action, **args)
def test_identical_action_with_reordered_keys(self):
self.assertTrue(self.allowed(dict(reversed(list(self.action.items())))))
def test_changed_amount(self):
self.assertFalse(self.allowed(self.action | {"amount_minor": 2600}))
def test_changed_target(self):
self.assertFalse(self.allowed(self.action | {"target": "order-18"}))
def test_changed_tenant(self):
self.assertFalse(self.allowed(self.action | {"tenant": "tenant-b"}))
def test_expiry_boundary(self):
self.assertFalse(self.allowed(self.action, now=200))
def test_reviewer_permission_revoked(self):
self.assertFalse(self.allowed(self.action, reviewer_allowed=lambda r, t: False))
def test_unknown_field(self):
self.assertFalse(self.allowed(self.action | {"destination": "elsewhere"}))
def test_boolean_amount(self):
self.assertFalse(self.allowed(self.action | {"amount_minor": True}))
def test_missing_approval(self):
self.assertFalse(permits(None, self.action, current_tenant="tenant-a", now=100,
reviewer_allowed=lambda r, t: True))
if __name__ == "__main__":
unittest.main()
The successful case proves that the chosen predicate accepts the unchanged action. The denied cases cover a changed amount, order or tenant, the expiry boundary, a revoked reviewer, an unknown field, a boolean amount and a missing approval.
These tests establish the fixture's decisions. They do not establish that a deployed identity provider, database, agent framework or payment adapter supplies trustworthy inputs.
Approval validity and replay protection need separate checks
Calling permits twice can return True twice. This predicate does not consume an approval, reserve a request or prevent two workers from executing the same action.
The executor needs durable request state and a concurrency-safe transition into execution. Keep the approved snapshot unchanged and pass the same validated fields to the adapter. Rebuilding a provider request from fresh model output after checking the digest breaks that connection.
If an approval includes a business-state version, check that version at the appropriate transactional boundary too. A snapshot can remain byte-for-byte identical while the underlying order or account changes. Authorization and state checks need to remain valid at execution, not merely earlier in the conversation.
Use a provider's supported idempotency mechanism where appropriate. Stripe's idempotency documentation describes its request-key behaviour and parameter comparisons. Provider idempotency helps reconcile repeated requests, but it does not prove that a human authorised their contents.
Treat a timeout as an unresolved result
If execution times out after a request may have reached the provider, inspect the durable attempt and provider-supported status before issuing another consequential request. Do not turn an unknown result into a new approved action simply to clear the queue.
Keep audit evidence for the reviewed snapshot, approval identity, validation decision, execution attempt and confirmed outcome. Minimise private payloads in logs and restrict access to operational records.
The next useful integration test is a controlled adapter that pauses between approval and execution. Mutate the proposed action, revoke the reviewer's permission or race two workers. Then inspect both the application decision and the external side effect. A pure predicate and its tests are the starting point for that proof.
This article and its educational fixture were prepared with AI assistance. The nine local tests use synthetic data.
Top comments (0)