# how resolve investigates disputed bank transactions

Published: 2026-10-21 | 7 min read
URL: https://oluwadarasimi.me/writing/how-resolve-investigates-disputed-transactions

when a customer disputes a payment, someone at the bank has to find out what happened. they open the transaction, check whether the debit was ever matched by a credit or a reversal, look at the settlement, compare the device and city with the customer's usual ones, and check the merchant. most disputes are routine. a few are fraud. the routine ones cost the same analyst time as the hard ones.

resolve is an ai agent that does that investigation. it recommends one of three outcomes: resolve, escalate or reject. every recommendation comes with a confidence score, cited evidence and a draft message to the customer. the interesting part isn't the model, though. it's everything around the model that stops it from being the one who decides.

i built resolve for a hackathon, and it runs on synthetic data only. it isn't a deployed banking product.

## why the model doesn't get the last word

a language model is good at reading records and writing up what it found. it's a poor choice for deciding whether to move money. it can be wrong, it can be talked into things by text a customer typed, and it can't explain itself in a way an auditor can check.

so i split the work in three. the model gathers evidence and proposes a decision. ordinary code checks that the evidence is real, applies a fixed policy, and is the only thing allowed to change a ledger. and an append-only log records every step, so a reviewer can see exactly what the agent saw.

that split drove every other choice. whenever the agent fails, in any way, it fails toward a person and never toward an automatic action.

## how a dispute moves through the system

in practice, disputes reach a bank through whatever channel the customer picks: a whatsapp message, an email, a call to customer support. each one ends up as a dispute in the system, with a transaction, a reason code and the customer's own words as notes. resolve's side of that is a single endpoint, `POST /disputes`. the api stores the dispute, puts its id on a queue, and returns straight away. today the dashboard is the only thing that calls it. the connectors that would turn a whatsapp thread or a call transcript into that request aren't part of resolve. nothing waits on the model. a worker picks the job up and runs the investigation, and the dashboard polls for progress, so the live trace of the agent's steps can lag by a second or two. that was a fair price for not needing websockets.

![overview: disputes arriving by whatsapp, email or support call are logged through the API, which queues a job for an investigation worker. The worker talks to the model, reads bank records and appends every step to an audit log.](/writing/resolve-overview.png)

_the channels on the left are how disputes arrive in real use. the rest runs on one cloudflare worker, with d1 for storage._

the investigation is a short, bounded conversation. the model gets eight read-only tools: one for the transaction, ledger entries, settlement status, device and location, customer profile, customer history, merchant info, and related transactions. it has at most eight turns, and it has to finish by calling a ninth tool, `submit_decision`, with a structured answer.

models misbehave in predictable ways, so the loop expects it. if the model doesn't call a tool, it gets one nudge. if its decision is malformed, the validation error goes back to it once. if it runs out of turns, one final call allows only `submit_decision`. and if even that fails, the agent returns a hard-coded decision that escalates with confidence zero. the case lands with a person.

the tools are narrow on purpose. each one checks that the record belongs to the customer in the dispute, so an injected instruction can't make the model read someone else's data. account numbers come back masked as `****1234`, and names are shortened. and free text that someone else wrote, such as merchant names, ledger memos and the customer's own notes, is wrapped in `untrusted` tags that the prompt tells the model to treat as data. that last one isn't a guarantee against prompt injection. it's one layer, and the layers behind it do the real work.

## the model proposes, the policy decides

when the agent finishes, its decision is treated like any other untrusted input. two checks run before anything happens, and the model has no say in either.

![decision flow: the model proposes a decision, an evidence check confirms every claim is real, and a fixed policy sends the case either to an executor or to an analyst.](/writing/resolve-decision-flow.png)

_the model only ever reaches the first box._

the first check is evidence. every decision has to cite the tool results behind its claims. the validator accepts a citation only if it points at a real tool call from this investigation that actually succeeded. models are bad at copying long ids, so the prompt asks them to cite short references like `S3`, meaning the third tool result, and the validator resolves each one to the real step. a claim that cites nothing real invalidates the whole decision.

the second check is policy, and it's a pure function. it collects every reason a case can't be handled automatically: invalid evidence, suspected fraud, an amount above the value cap, a confidence below the threshold, a dispute type that isn't allowed to auto-resolve, too little evidence. only a case with no reasons at all is resolved automatically. by default that means a failed transfer or a duplicate charge, with confidence of at least 0.9, under 50,000 naira, and at least two pieces of evidence. a rejection always needs an analyst to confirm it. suspected fraud always goes to a person. when a case needs review, the list of reasons is saved so the analyst sees why.

if the policy says yes, the executor posts the fix: a reversal for a failed transfer, or a refund of the later charge for a duplicate. the ledger entry, the transaction status and the dispute update commit as one atomic batch, so either all of it happens or none of it does. the executor is also safe to run twice, which matters because queue messages can be delivered more than once. and if it can't apply the fix, for example because no matching duplicate exists, the case drops to review instead of failing.

analysts can approve the agent's recommendation, override it with a reason, escalate it, edit the customer message, or retry a failed investigation. each action is recorded in the same batch as the change it causes.

## a log you can check

every step the agent takes is written to an audit log: the start of the investigation, each model turn, each tool call. each row stores a hash of the row before it, computed from the previous hash, the step's position, its kind, and its input and output. change or remove a row in the middle and every hash after it stops matching.

![audit chain: each step stores a hash that also feeds into the next step, starting from a block of 64 zeros.](/writing/resolve-audit-chain.png)

_a verifier walks the chain, recomputes each hash, and reports the first step that doesn't match._

i'm careful with the word for this. the chain is tamper-evident, not tamper-proof. it catches edits and deletions in the middle of a case. it can't catch steps cut off the end, or a whole chain rewritten from some step onward, because the final hash isn't stored anywhere outside the database. anchoring it elsewhere would close that gap, and i haven't done it yet.

the rule is also enforced in code. a test scans the source and fails if anything updates or deletes rows in the audit tables. that's why resolve has no reset button for demo data: any exception would have broken the rule the test protects.
