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.
