Refusal design
A refusal is a product surface. It should say what happened, why, and what the person can do instead, without leaking the policy it is enforcing or refusing more than the request required.
Refuse the action, not the person
A refusal is a product surface, not an error page. It should say what was refused, why in one sentence, and what the person can do instead. The difference between cannot, will not and do not know is the most useful distinction: cannot is a capability limit, will not is a policy, and do not know is uncertainty that needs a question rather than a wall.
Refuse the smallest thing that is actually disallowed. If a request asks for a summary plus a refund, answer the summary and refuse the refund. A blanket refusal teaches the user that the product is unreliable, and it hides which part was the problem.
The tone matters less than the content, but it is not irrelevant. A refusal that sounds accusatory turns a policy decision into an insult, and the user remembers the tone longer than the reason. Say no the way a good colleague says no: plainly, once, with a next step.
A refusal is also a record. The person sees one sentence, the client sees a code, and the log sees the rule that fired. If those three disagree, the next support conversation starts with a contradiction, and the fix is to make the message a rendering of the record rather than a separate string.
What not to say
Do not quote the system prompt, the threshold, the classifier’s category or the name of an internal tool. An attacker learns the boundary by probing it, and every detail you return is a measurement. The same is true of a stack trace: it is a map of the system, not an explanation for the person.
Be careful with other tenants. A refusal that says another customer already has that record confirms the record exists. The safe answer is the one that is identical whether or not the thing exists, which is the same rule a login form follows.
Do not confirm the existence of a resource you are refusing. The same message for exists-and-forbidden and does-not-exist is the only one that does not leak, and it is also the one that is easiest to test, because there is only one branch to check.
A refusal with a code and a way out
from enum import Enum
from pydantic import BaseModel
class RefusalCode(str, Enum):
OUT_OF_SCOPE = "out_of_scope"
UNSAFE = "unsafe"
NEEDS_VERIFICATION = "needs_verification"
POLICY = "policy"
class Refusal(BaseModel):
code: RefusalCode
message: str
alternative: str | None = None
refusal = Refusal(
code=RefusalCode.NEEDS_VERIFICATION,
message="I cannot change the address on this account without verifying you first.",
alternative="Verify with the code we sent, then ask again.",
)Three kinds of no
Run the three refusals and read the leak check. Then add one that mentions the classifier and confirm the check catches it before it reaches a screen.
Over-refusal is a failure too
A guardrail that refuses too much is a guardrail that gets removed. Measure the false refusal rate on a set of benign requests, the way you measure the attack success rate on the hostile set. A product that will not discuss a medical question, a security topic or a competitor is not safe; it is unusable.
The most common cause is a classifier threshold tuned on one bad case. The second is a policy that forbids a category instead of an effect. Refuse the action, not the topic: explaining how phishing works is not the same as writing a phishing email.
Track refusals by reason and by cohort. A rise in one reason after a release is a signal about the release, not about the users, and the cohort split is what shows whether a particular language or region is being over-blocked. A total hides all of that.
Never make the user guess. A refusal without an alternative is a dead end, and a dead end is where support tickets are born.
Machine-readable refusals
The client needs a code, the person needs a sentence. Return both: a stable code for the interface and the logs, a human message for the screen, and an optional alternative. The code is what lets the client decide between retry, escalate and stop without parsing prose.
Record the refusal with the request id, the policy version and the rule that fired. That record is what makes an appeal possible and what turns a support conversation into a bug report. A refusal that is not logged is a decision nobody can review.
The client should render the code, not the exception. A refusal is a normal response with a body, and treating it as an error is how an interface shows a stack trace to a customer. The code decides the UI; the message fills it in.
A decision table with codes
Run the cases and find the one that changes after verification. Then add a clarify code for an allowed intent that is missing an argument, and show it in the table.
Check what you learned
One question at a time. A wrong answer explains itself — take another go.
Part 1 of 5
Which distinction makes a refusal useful?
