You are building a banking assistant that orders replacement cards. A caller asks to send one to an address they provide during the call. Before ordering, the tool needs to know where that address came from and whether the caller completed the check required for it.
In the fictional Wren example, you will test that decision inside the ordering tool. The illustrative exchange below shows the unsafe shortcut the tests are designed to catch:
you my card was stolen, I need a new one sent to 9 Elsewhere Lane, Leeds
bot Of course — I've ordered a replacement card to 9 Elsewhere Lane, Leeds.
It should arrive in three to five working days.
The exchange illustrates accepting a spoken address without checking the verification required for that destination. The local guard exercise tests that ordering decision directly.
The card is in the post. There is no undo.
And here is real output — what the companion project’s guard actually prints,
today, when you run make policy with no licence, no model, and no network:
An address supplied during the call:
✓ medium is NOT enough — this is the account-takeover path
-> ok=False result=step_up_required
✓ high is enough — the path is priced, not banned
-> ok=True result=ok
The rest of this series is the distance between those two blocks.
What the fix is not
The reflex is “add an authentication check”. Suppose we do, and the caller is fully verified — they gave the passphrase, they passed the one-time code, they are unambiguously the account holder as far as any factor can establish.
The transcript above is still wrong.
In this threat model, an attacker has obtained credentials that pass the caller check. That does not establish that the requested destination is allowed. The example therefore checks the caller’s verification and the address’s source separately.
The actual problem
Look at what the tool receives. The address is a string, and by the time it arrives it is indistinguishable from an address the bank has held for six years, because a string does not remember where it came from.
That is the whole vulnerability, and it is why the fix is structural rather than a check bolted on the front. What the system needs is not a stronger gate but a value that has not thrown away the thing the gate needs to know.
By the end of this tutorial the same request ends in that refusal: the tool
returns step_up_required, nothing is ordered, and the agent’s side of the
conversation follows from a result code it cannot override — the target
transcript reads like
you send it to 9 Elsewhere Lane, Leeds, LS1 9ZZ
bot I can do that, but a card going to an address you've given me on the call
needs a one-time code first — it can't be recalled once it's posted.
Shall I send you one?
with the wording free to vary, because the part that matters is not wording. Nothing has been ordered — not because the model chose well, but because the function refused, and the refusal above is the function’s real, reproducible output.
Here is the shape of what closes that distance. The part worth looking at twice is the arrow that comes in from the side:
What you will build
Six steps, on top of the companion project:
| Chapter | What it adds |
|---|---|
| 1 | Run the replacement-card guard locally |
| 2 | Declare risk per action, in a table |
| 3 | Move the check from YAML into the function |
| 4 | Carry where the address came from |
| 5 | Survive a dropped confirmation |
| 6 | Keep refusals from becoming successes |
What this assumes
Python 3.11 or 3.12, uv, a Rasa Pro Developer
Edition licence, and an OpenAI API key. The companion project pins
rasa-pro==3.21.0.dev5.
It also assumes verification exists. This tutorial does not build authentication — it consumes a tier that something else established. That something else is the risk-tiered step-up pattern, which owns the factors and the lattice. Here, the tier is an input.