Everything so far guarantees that the card is not posted. There is one failure mode left, and it is not in the Python.
The tool returns {"ok": False, "message": "..."}. The model reads the
message, finds it sympathetic, and tells the caller their card is on its way.
Nothing crashed. The log says the tool refused. The caller heard a promise.
Outcomes as a closed set
class Result(str, Enum):
OK = "ok" # a card was ordered
STEP_UP_REQUIRED = "step_up_required" # nothing happened; caller can fix it
COOLING_OFF = "cooling_off" # nothing happened; caller cannot
DUPLICATE = "duplicate" # nothing NEW happened
REFUSED = "refused" # nothing happened; ever
Five values, and the skill prose is written against categories rather than against message text somebody will reword next month.
The distinction between STEP_UP_REQUIRED and COOLING_OFF is the one that
earns its keep. Both are refusals. Only one of them can be resolved on this
call — and an agent that offers to “try again in a moment” after a cooling-off
refusal is offering a workaround to a control that exists specifically to have
no workaround.
One expression decides whether something happened
@property
def acted(self) -> bool:
return self.result in (Result.OK, Result.DUPLICATE)
and at the boundary:
payload = {"ok": outcome.acted, ...}
ok is derived, never set by hand. Every non-OK branch has at some point
been mistaken for a soft success by somebody in a hurry, and the point of a
single named property is that there is one thing to get right instead of five.
The constructor enforces the other half:
def refused(result, message, **detail) -> Outcome:
if result in (Result.OK, Result.DUPLICATE):
raise ValueError(f"{result.value!r} is not a refusal; build it with succeeded()")
return Outcome(result=result, message=message, reference=None, detail=detail)
A refusal cannot carry a reference. A reference is a promise that something exists, and nothing does.
A refusal never carries a reference:
✓ refused payload has no reference key
Saying it in the skill
The Python makes the promise unsupportable. The prose stops it being made:
Never say a card has been ordered, is on its way, or will arrive unless
`reissue_card` returned ok true together with a reference.
If `reissue_card` returns cooling_off, nothing has been ordered and nothing the
caller says on this call will change that. Explain that the address is too new
to post a card to, offer the older addresses on file, and if none works offer
@skill.human_handoff. Do not offer to note the request, raise it later, send it
somewhere else as a workaround, or try again in a moment.
That last sentence is doing real work. Left to itself, a helpful model will find an adjacent thing to offer, and every one of those adjacent things is a way around the control. The forbidden alternatives have to be named, because “do not proceed” and “do not achieve the same outcome another way” are different instructions.
The escalation boundary
The agent orders replacement cards to addresses that pass policy. It does not
change an address, override a cooling-off window, verify identity itself, or
retry a refused reissue another way. When any of those is what the caller
needs, it hands to @skill.human_handoff and stops.
Write that down, in the README and in the skill, as a behaviour rather than an apology. A refused card request that ends in a human is the system working.
Where to go next
This tutorial took the caller’s verification tier as an input and never asked
how it got there. That question — what the tiers are, which factor buys which,
and how a caller steps up mid-call — is the subject of
the risk-tiered step-up pattern,
which classifies reissue_card at its highest tier.
The two compose. That pattern decides how strong the caller’s verification is; this one decides whether, for this particular destination, that is enough.
