Skip to content
RasaGet a free licence
Menu

tutorial

Chapter 4 of 6

Distinguish a saved delivery address from one supplied in chat

by Rod Rivera Published

Compare the requested delivery address with the customer’s saved addresses before choosing the required verification.

A customer asks the banking assistant to send a replacement card to an address supplied during the conversation. The address has the same street, city and postcode fields as a saved one, but it may need a different verification check.

You will classify the address by comparing it with the fixture’s saved records. The code calls that source classification “provenance”: it records whether the address was already on file or came from the caller.

class AddressProvenance(str, Enum):
    ON_FILE = "on_file"   # in the record before this conversation started
    STATED  = "stated"    # said by the caller during this call. Unverified.
    UNKNOWN = "unknown"   # provenance was lost. Treated as STATED-or-worse.

The signature is the design

def classify_address(line1, city, postcode, addresses_on_file) -> ClassifiedAddress:

Read what is not there: no provenance parameter.

A function that accepts the answer to the question it is supposed to decide is not a check. It is a formality that the caller, the model, or a future refactor can satisfy by passing the convenient value. So this function takes the customer’s real address list and compares against it. Provenance is looked up, never asserted.

Normalising, and why it is a kindness not a hole

def _normalise(value: str) -> str:
    return " ".join(value.split()).replace(" ", "").casefold()

BS1 4TR, bs1 4tr, and BS14TR are the same postcode. Without this, a caller who reads their own on-file address back slightly differently is classified STATED and charged a one-time code they did not owe.

That is not merely annoying. An agent that demands extra verification from legitimate callers at random is an agent whose extra verification gets removed by someone six months from now who is tired of the complaints — and they will remove all of it, including the part that was load-bearing.

Why STATED is allowed at all

In this fictional service policy, a customer may request delivery to a new address. The tool requires the higher verification tier for that choice; an address already on file has a different requirement.

That is a chosen teaching policy, not a claim about which customers move or how attackers behave. A real issuer must decide whether to allow a newly supplied address and which verified evidence it requires.

✓ medium is NOT enough — this is the account-takeover path
✓ high is enough — the path is priced, not banned

The gap this leaves, and closing it

There is a hole in what we have so far, and it is worth finding before someone else does.

If ON_FILE is the cheap path, an attacker’s move is to get their address on file — go through whatever flow adds an address, then order the card at medium. The provenance check passes honestly. The address really is on file.

So being on file is not enough; being on file for a while is:

COOLING_OFF = timedelta(days=7)
    if (
        address.provenance is AddressProvenance.ON_FILE
        and on_file_since is not None
        and (today or date.today()) - on_file_since < COOLING_OFF
    ):
        # refuse: cooling_off

Note where this check sits: it applies only to ON_FILE, precisely because ON_FILE is the discounted path. An address that became on-file recently has not yet earned the discount that being on-file buys. Without this, the distinction Chapter 4 built is a speed bump with a marked detour around it.

Seven days is a policy number, not a technical one. It is named as a constant so that the one place it lives is the one place it gets argued about.