Authority levels for finance AI agents: a small policy model you can test
Most discussions about AI agents in finance stop at "keep a human in the loop". That sentence is true and almost useless to an engineer, because it does not say which loop, which human, or what happens when the human is
Most discussions about AI agents in finance stop at "keep a human in the loop". That sentence is true and almost useless to an engineer, because it does not say which loop, which human, or what happens when the human is not there. This post is about turning it into something you can write down, review and test.
Four actions, four levels of authority
Start by listing what an agent can actually do. In a finance system, the list is short:
- Notify: send an internal reminder or flag an exception.
- Draft: build a journal entry, a payment proposal or a report, without posting it.
- Post: record a final, balanced entry in the ledger.
- Pay: initiate an outgoing payment.
These are not the same risk. A wrong reminder costs a minute. A wrong posted entry changes a report someone already used. A wrong payment may not come back. So each level gets its own rule: which role may allow it, up to what amount, whether a second approval is needed, and what stops it.
Policy as data, not as prompt text
The tempting shortcut is to put the rules in the agent's prompt ("never pay more than 5,000 without approval"). Prompts are not a control. They are not versioned with the books, they cannot be audited, and a model can misread them. Put the policy in data, and let deterministic code enforce it after the model has proposed something.
type Level = "notify" | "draft" | "post" | "pay";
interface Rule {
level: Level;
allowedRoles: string[];
maxAmount?: number; // in reporting currency
secondApproval: boolean;
stopIf: StopCondition[];
}
type StopCondition =
| "destination_account_changed"
| "over_limit"
| "missing_document"
| "unusual_amount_change"
| "sources_disagree";
const policy: Rule[] = [
{ level: "notify", allowedRoles: ["agent"], secondApproval: false, stopIf: [] },
{ level: "draft", allowedRoles: ["agent"], secondApproval: false, stopIf: ["sources_disagree"] },
{ level: "post", allowedRoles: ["accountant", "controller"], maxAmount: 20000,
secondApproval: false,
stopIf: ["missing_document", "unusual_amount_change", "sources_disagree"] },
{ level: "pay", allowedRoles: ["treasurer"], maxAmount: 10000,
secondApproval: true,
stopIf: ["destination_account_changed", "over_limit", "missing_document"] },
];
The numbers above are examples. The point is the shape: every action the agent proposes is checked against a rule it did not write.
The check happens outside the model
The agent proposes. A plain function decides.
function decide(p: Proposal, rule: Rule, ctx: Context): "run" | "wait" | "stop" {
const hit = rule.stopIf.find(c => ctx.detects(c, p));
if (hit) return "stop"; // a person must look
if (rule.maxAmount && p.amount > rule.maxAmount) return "wait";
if (!rule.allowedRoles.includes(ctx.instructedBy.role)) return "wait";
if (rule.secondApproval && !ctx.hasSecondApprover(p)) return "wait";
return "run";
}
Three outcomes matter. Run means the action is inside what the organization already approved. Wait means it needs a named person. Stop means something looks wrong and nobody should approve it without reading the evidence first.
Because decide is ordinary code, you can unit-test it: a payment to a changed bank account must always return stop, whatever the model said in its explanation.
Keep facts, inferences, suggestions and decisions apart
When an agent explains itself, it mixes four kinds of statement:
- a fact that came straight from a source (a bank line, an invoice),
- an inference made by combining data,
- a suggestion for the next action,
- a decision that only an authorized person can make.
Store them as separate fields, not one paragraph. A reviewer can then see at a glance that "this invoice is probably a duplicate" is an inference, and that the decision field is still empty.
Log every run so it can be replayed
For each run, record the time, the sources read, the version of the rule used and the result. If a recurring workflow runs monthly, a change in the rule creates a new version instead of silently changing old results. That is what lets an auditor ask "why was this posted?" months later and get an answer that is not "the model thought so".
Arithmetic is not the model's job
Let the model read documents, extract lines and propose matches. Let deterministic code do the calculation, the debit-equals-credit check and the final posting. Balances should be the sum of entries, and corrections should be reversing entries, never edits.
If you are designing something similar, the one test worth writing first is the boring one: feed your decision function a payment whose destination account changed yesterday, and make sure it stops.
This article was written with the help of AI and reviewed for accuracy before publishing.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.