Vocabulary for this course

The terms the chapters assume, in plain words. You can open this from any chapter.

allow-list
A list of what is permitted, with everything not on it refused by default.
fail closed
Behaving so that an error blocks the action rather than letting it through unchecked.
false refusal
A refusal of a request that was actually allowed, which costs a real user an answer.
incident
An event where the system did something harmful or outside policy, handled from detection to review.
least privilege
Giving each part of the system only the access it needs for its current job, and no more.
moderation
Checking content against rules about what is acceptable, and blocking or flagging what is not.
normalisation
Rewriting input into one standard form, such as lower case or expanded characters, before checking it.
PII
Information that can identify a person, alone or combined with other details, such as a name or address.
policy
A written rule that says what the system must allow, block or escalate, kept in code with a change history.
prompt injection
Text in input or retrieved content that tries to make the system follow the instructions of an attacker instead of yours.
provenance
The record of where a piece of content came from and how it reached the system.
red team
People or scripts that attack the system on purpose to find failures before real users do.
refusal
A reply that declines a request and says what the system can do instead, without revealing protected details.
threat model
A written list of what you are protecting, who might attack it and how, made before you build.

Guardrails and safety

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.

Part 5 of 10

Intermediate13 min readBack to Courses

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.

Output
Click "Run" to execute your code

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.

Output
Click "Run" to execute your code

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?