Step-up routing
STEP_UP means "not enough to approve". The right friction depends on why: route on signals, not on the decision alone.
d = client.decide("payment", txn)
ROUTES = { # card analyzer signal codes
"device_new_for_card": "otp", # an OTP to the known phone resolves it
"three_ds_absent": "three_ds", # ask the issuer for a 3-D Secure challenge
"sim_swap_recent": "review", # an OTP would go to the attacker: a person decides
"impossible_travel": "review",
}
if d.decision == "STEP_UP":
route = next((ROUTES[s] for s in d.signals if s in ROUTES), "review")
if route == "review" and d.score_kind == "calibrated" and d.score < 0.25:
route = "otp" # low calibrated risk: soft friction first
dispatch(route, d.id)
The same pattern applies to KYC: a PHONE_NOT_CONFIRMED reason means "send the code", not "send to compliance". Map reason codes to UI actions, and only queue for people what needs a person.
Always keep d.id with the action you took. When the step-up resolves, send feedback: those labels are what lets Ward step up less over time.