Skip to content

tutorial

Chapter 4 of 6

Chapter 4 — Carry where the address came from

by Rod Rivera Published

A fact on file and a fact said on the call are different values. Make the difference part of the value, decided by lookup rather than assertion.

This is the chapter Chapter 1 was pointing at. Two addresses reach the tool as identical shapes, and the difference that matters was thrown away at the boundary.

So stop throwing it away. cardpolicy/provenance.py:

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

The strict-looking answer is to refuse every new address. It is also wrong.

People move. And the customer whose card was stolen along with their wallet is precisely the customer most likely to have moved recently, or to be standing somewhere that is not home. Refusing every stated address makes the agent useless for the exact case it exists to handle, and a useless safe path pushes people to the phone queue, where the social engineering works better anyway.

So the harder path is priced, not banned: STATED costs high instead of medium.

✓ 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.