Business email compromise is the most reliable way to steal money from a finance function, and it works without touching a single system. One convincing email updates the vendor master, and Friday's payment run does the rest in bulk. This demo puts a deterministic release gate between the vendor master and the bank file. Red-flag rules quarantine suspicious payments before the file exists, a language model assembles the cited anomaly pack for each hold, and money leaves only after an evidenced call-back to the pre-change phone number plus two different named approvers. The demo hands you the attacker's chair first, so you can watch your own fraud die in quarantine.
Payment run modelled on the City of Chicago Payments dataset (s4vu-giwb) · vendor bank layer, change log and all attacks synthetic · deterministic engine, identical results every run
The pattern is depressingly standard. A "vendor" emails updated bank details on plausible letterhead. An AP clerk updates the vendor master, because processing detail changes is the job. Invoices then approve through the normal flow, since nothing about them is unusual: right vendor, right amounts, right coding. On Friday the payment run generates the bank file and treasury releases it in bulk. The real vendor calls about non-payment three weeks later, the recovery window is long gone, and the insurer and the bank begin arguing about whose negligence it was.
The scale of this is documented. AFP's payments fraud survey reports that 79% of organisations experienced attempted or actual payment fraud, with business email compromise the leading vector. The FBI's IC3 attributes US$2.77 billion in reported BEC losses in a single year, and reported losses undercount by construction. Meanwhile only a minority of AP teams apply any automated screening to outbound payments; the industry brief this demo responds to cites 17% using AI against it. Where a control exists at all, it is usually "call the vendor if something feels off", which is a sentiment, and sentiments do not fire at 4pm on a Friday with 182 payments in the queue.
The tempting move is to paste the change-request email into an LLM and ask whether it looks fraudulent. That fails for structural reasons that better prompting cannot repair:
So the screening is a deterministic rules engine over the vendor master, the change log and the run itself. The language model gets exactly one job: it assembles the anomaly pack and drafts the call-back script for each payment the rules already held, and every value it cites is checked against the record by code before it is displayed. Where there is no evidence, it abstains and says so.
Four red-flag rules screen every payment in the run before any bank file is generated: a bank-detail change within 14 days of the run (R1), a first-time payee at or above US$10,000 (R2), a remittance account whose country no longer matches the vendor's (R3), and a sequence of payments to one vendor each sitting just under the US$25,000 single-approver limit (R4). A rule hit quarantines the payment. The model cannot quarantine anything and cannot release anything. Release requires a recorded call-back to the phone number that was on file before the change, never the number in the email, followed by approvals from two different named users. The user interface physically blocks a solo release, and every refused attempt is logged with the missing evidence named.
Here is the release decision from the shipped file. It is deliberately small, because a control this consequential should be readable in one breath:
// The release decision. This is the whole gate: no call-back evidence or no // second distinct approver means the function refuses, whoever is asking. function releaseDecision(rel) { const missing = []; if (!rel.callback) missing.push("a recorded call-back to the pre-change number on file"); const ids = [...new Set(rel.approvals)]; if (ids.length < 2) missing.push(`a second, different named approver (${ids.length}/2 so far)`); return { allowed: missing.length === 0, missing }; }
The deduplication through Set is the dual-control: the same named user approving twice still counts as one. And because the function returns the missing evidence rather than a bare no, every blocked attempt lands in the ledger with its reason attached.
The demo opens with the attacker's console because the fastest way to trust a control is to fail against it. You pick a real vendor to impersonate, and one click does what the real fraud does: a lookalike-domain email lands, the vendor master updates to a GB account with no verification, and a fresh US$48,750 invoice joins Friday's queue. At that point nothing has stopped you, which is the honest state of most AP processes.
Then you run the screen. Your payment quarantines on R1 + R3: the bank details changed two days before the run, and the account now sits in a country the vendor has never banked in. The anomaly pack cites the exact change-log line you created, quotes your own email back at you, and drafts the call-back script pointed at the phone number that was on file before your change. When you try to push your own release through solo, the gate refuses and logs the attempt. Your fraud is now stuck behind a phone call to the real vendor, which is exactly where it dies.
Every figure on this page is live output of the deterministic engine. Over the 182-payment run: 12 of 12 seeded BEC patterns quarantined (16 of 16 attack payments) before the bank file was generated; 21 payments held, 161 written to the file, hash printed; 5 of 166 legitimate payments (3.0%) held one extra day; and 0 releases without call-back evidence plus two distinct named approvals, with every blocked attempt logged. The engine is seeded and client-side, so a third party gets identical quarantines, identical reasons and identical hashes on every run.
The payments are real: an 1,800-row slice of the City of Chicago's public Payments dataset, fetched from the city's open API with the script shipped alongside the app, with real vendors, amounts and voucher numbers. Everything banking-related is synthetic and labelled, because real bank account data is never public, by design; Chicago's dataset carries no banking fields at all. Real vendors cast in attack roles are victims of impersonation in the scenario, and the app says so.
The honest limits, stated flatly:
Impersonate a vendor, send the fake bank-change email, run Friday's payments, and watch your own attack land in quarantine with the change-log line that betrayed you quoted back. Try to release it solo. Then record the call-back, gather the second approver, and check the amended bank-file hash. Tamper with the ledger afterwards and watch Verify locate the broken row.
Most payment-fraud tooling watches the transaction: the amount, the counterparty, the pattern. I placed this control one step earlier, at the vendor-master boundary, because that is where BEC actually happens; the payment itself is usually flawless by the time it exists. Years of testing controls as an auditor taught me that the cheapest place to stop a loss is the last gate it must pass, and that a gate is only real if it binds in code, keeps its evidence, and refuses politely when a senior person asks it to hurry. That refusal, logged with its reason, is the part I would show an examiner first.