Agent failure labs / Guide 63
Expense Policy Enforcement Must Aggregate Split Claims
Reproduce an AI expense policy enforcement failure where split meal claims pass individually, then aggregate the policy unit and route exceptions for review.
Illustrative engineering guide. Examples are not client deployments, production-ready integrations, or evidence of measured results. Validate the design with your domain and security owners.

Two meal receipts arrive in a hypothetical expense report: forty euros for lunch and thirty-five for dinner on the same travel day. The company’s illustrative policy caps daily meals at sixty euros. An assistant checks each receipt against sixty, marks both compliant, and prepares the report for approval. The arithmetic is correct. The unit of enforcement is wrong.
This lab focuses on splitting a daily allowance across claims. It does not recreate the broader expense approval workflow. The bug can occur without fraud, prompt injection, or a bad model: a perfectly ordinary extraction pipeline hands an item-level validator a rule defined at the employee-day level. Engineering must preserve that relationship all the way to review and posting.
Identify the Policy’s Counting Unit
Write the rule in operational language before implementing it: aggregate meal expenses for the same tenant, employee, travel date, and currency, including already submitted claims. The sixty-euro limit below is an invented policy value for the exercise, not a tax rule or externally validated allowance. Real policies may distinguish location, provided meals, attendees, or per diem treatment.
Keep monetary values out of binary floating-point calculations. Python’s decimal documentation explains exact decimal representation and configurable arithmetic. Decimal arithmetic prevents some rounding surprises, but it cannot decide which receipts belong in the aggregate. A precise sum over the wrong records remains a precise mistake.
The fixture contains two rows from the same employee-day. The naive result approves both independently. The fixed result identifies the whole group as requiring finance review and preserves the original rows. It parses each travel date into a canonical day before grouping, so equivalent date strings cannot split one allowance. It does not reject reimbursement, approve an exception, or post an accounting entry. Those are distinct decisions with their own authority.
from decimal import Decimal
from datetime import date
from collections import defaultdict
from copy import deepcopy
rows = [
{"id": "lunch", "tenant": "A", "employee": "E1", "day": "2026-09-06",
"currency": "EUR", "category": "meal", "amount": "40.00"},
{"id": "dinner", "tenant": "A", "employee": "E1", "day": "2026-09-06",
"currency": "EUR", "category": "meal", "amount": "35.00"},
]
limit = Decimal("60.00")
def naive(items):
return {r["id"]: Decimal(r["amount"]) <= limit for r in items}
def guarded(items, actor_tenant):
if any(r["tenant"] != actor_tenant for r in items):
return {"status": "denied"}
if len({r["id"] for r in items}) != len(items):
return {"status": "duplicate_receipt"}
totals = defaultdict(Decimal)
for r in items:
amount = Decimal(r["amount"])
if not amount.is_finite() or amount <= 0:
return {"status": "invalid_amount"}
if r["currency"] != "EUR" or r["category"] != "meal":
return {"status": "unsupported_policy"}
try:
day = date.fromisoformat(r.get("day")).isoformat()
except (TypeError, ValueError):
return {"status": "invalid_day"}
key = (r["tenant"], r["employee"], day, r["currency"])
totals[key] += amount
return {"status": "finance_review" if any(v > limit for v in totals.values())
else "policy_checks_passed", "totals": dict(totals)}
before = deepcopy(rows)
assert all(naive(rows).values())
assert guarded(rows, "A")["status"] == "finance_review"
assert list(guarded(rows, "A")["totals"].values()) == [Decimal("75.00")]
assert guarded(rows[:1], "A")["status"] == "policy_checks_passed"
assert guarded(rows, "B")["status"] == "denied"
assert guarded(rows + rows[:1], "A")["status"] == "duplicate_receipt"
assert guarded([dict(rows[0], amount="NaN")], "A")["status"] == "invalid_amount"
mixed_dates = [rows[0], dict(rows[1], day="20260906")]
assert all(naive(mixed_dates).values())
assert guarded(mixed_dates, "A") == guarded(rows, "A")
assert mixed_dates[1]["day"] == "20260906"
assert guarded([rows[0], dict(rows[1], day="2026-09-07")], "A")["status"] == "policy_checks_passed"
assert guarded([dict(rows[0], day="2026-02-30")], "A")["status"] == "invalid_day"
assert guarded([dict(rows[0], day=None)], "A")["status"] == "invalid_day"
assert rows == before
This is a synthetic deterministic simulation, not an experiment conducted against an actual LLM, receipt reader, or finance platform. The fixture values are already structured. The date regression uses Python 3.11 or later, where date.fromisoformat accepts the compact ISO date form. Successful assertions establish the shown aggregation behavior, not receipt authenticity, accounting correctness, or compliance with a company’s complete policy.
A Prompt Cannot Supply Missing Ledger Rows
Telling a model to “check the daily total” cannot work if its context contains only the current receipt. The validator needs a complete view of the policy unit. OWASP’s input validation guidance distinguishes syntactic checks from semantic checks. Date formatting and valid amounts are syntactic concerns; inclusion in a daily allowance is a semantic relationship that must be enforced server-side.
Normalize candidate receipt facts separately from verified evidence. Store the extracted amount, currency, date, receipt identifier, and source span, then let an employee correct errors. A restaurant name is not reliable evidence of expense category. If classification changes from meal to lodging, require evidence and reevaluate the applicable policy instead of allowing the assistant to select a cheaper rule.
The smallest useful architecture is a receipt intake component, an authoritative expense repository, a versioned policy evaluator, and a finance review queue. A specialist extractor earns its complexity for difficult receipt formats; a second policy-writing agent does not earn authority to reinterpret a cap. Keep exceptions explicit, attributable, and separate from the base rule.
Enforce the Aggregate at Submission
The production read must include relevant draft, submitted, and posted items according to an approved policy definition. Two browser tabs can otherwise each see remaining allowance and both proceed. PostgreSQL’s transaction isolation documentation explains why separate reads can observe different snapshots and why serializable transactions can require retries. Choose a transaction strategy that protects the whole counting unit, not just individual receipt rows.
Do not hold a database transaction open while waiting for an employee to respond. Create a versioned review proposal, release the transaction, then revalidate when the person submits it. If another claim changes the daily total, explain the difference and refresh the review. A human approval tied to an old report version must not authorize newly added receipts.
The lab omits the authentication source, database transactions, provider semantics, approval persistence, and durable posting state. Tenant and employee identity must come from server-side authorization and delegated submission rules, not receipt text. Routine logs should contain operation identifiers and reason codes, not card numbers, full receipts, or personal travel details. Uploaded instructions to “ignore meal limits” have no policy authority.
Posting is another boundary. Deduplicate the business operation with a persistent report identifier and confirmed payload. If the accounting connector times out, mark posting unknown and reconcile against the authoritative ledger before retrying. Check receipt origin, report version, amount, and tenant; status: success alone is insufficient. Never offset a suspected duplicate with an automatic reversal based only on model speculation.
Acceptance Tests
- Two receipts below the item-level cap but above the daily cap must reproduce naive approval and fixed finance review on identical rows.
- Equivalent ISO date representations must share one allowance; malformed or missing dates must stop evaluation.
- Claims from different travel days must not be merged into one allowance unless the policy explicitly defines a trip-level unit.
- Duplicate receipt identifiers in one report must stop evaluation rather than inflate totals or disappear silently.
- Unsupported currencies must require an approved conversion policy and rate provenance, not an assumed one-to-one exchange rate.
- Concurrent submissions for the same employee-day must not both validate against the same unclaimed remaining allowance.
- A report edited after approval must require renewed review, while a timeout after posting must remain unresolved until ledger reconciliation.
Roll Out a Review Aid First
Use a labeled corpus of synthetic expense groups with known totals as the baseline. Measure missed policy exceptions divided by all groups requiring exceptions, not by all receipts. Measure unnecessary finance referrals over compliant groups as a separate operational cost. Report performance for mixed currencies and missing receipts independently; removing difficult groups makes a reassuring aggregate meaningless.
Start by showing finance reviewers the computed counting unit and included receipt identifiers. Proposed promotion criteria include complete group provenance and no missed split-claim cases in the fixture matrix. These are rollout requirements, not measured customer results. Preserve a manual route for policy ambiguity and monitor how often reviewers correct grouping rather than extraction.
The expense approval guide covers the larger process; the outdated policy lab tests which policy version belongs in this calculation. AI systems engineering should begin by making the aggregation key visible to reviewers. That small change exposes a failure that polished item-level explanations tend to conceal.