Chapter 7 of 8 · 12 min read
Denial paths and silent downgrades
On this page
A sampling exchange has two audiences and they receive different information. The server receives the result, which names the model actually used and the stop reason, and is therefore fully informed about what happened. The user receives whatever the agent renders, which is the tool's eventual output, several layers removed from any of it. The point from chapter 1 that the client selects the model and reports the selection was about the server's ability to notice; the user has no equivalent channel unless the client builds one, so any substitution the client makes is by default unobservable to the person paying for the result and relying on it.
That asymmetry is what makes silent fallbacks so easy to ship. The code that adds one is small, it is written to fix a real problem, it passes every test because the request still returns a completion, and its failure mode is a quality regression distributed thinly across weeks. Compare it with a content delivery network, or CDN — the cache of edge servers that sits in front of an origin and answers on its behalf — serving stale content, which is a deliberate degradation that nearly every implementation announces in a header precisely because the consumer's correct behaviour depends on knowing. Here the consumer is a human reading a summary, and the header does not exist.
That comparison is a comparison and not a mechanism, and where it breaks is where the problem actually lives. A stale cache returns content that was true earlier and remains checkable: fetch the origin, compare the two, and the staleness is a fact anyone can establish after the event. A completion produced on a smaller model is not old, it is different, and there is no origin to compare it against — the request that would have produced the better answer was never made, and the conversation state that would let you make it now has moved on. So the CDN's header saves a consumer a check they could have performed for themselves, while the equivalent disclosure here is the only route by which the fact can be established at all.
Five endings, and where each one has to surface
| Ending | Frame to the server | What the user sees | What is recorded |
|---|---|---|---|
| Denied by policy | -32001, retryable: false | nothing inline if the tool degrades cleanly; an entry in the session's activity log naming the server and reason | outcome: "denied", reason, zero cost |
| Denied by the user | -32001, retryable: false, distinct reason | the dialog they just dismissed, plus a durable note that this server was denied for the session | reason: "user.denied", the identity, so it is not asked again |
| Over budget | -32003 with resetsAt | an inline statement in the agent's output: this integration has reached its daily limit, with an affordance to raise it | outcome: "denied", reason: "policy.budget_exhausted", spend to date |
| No permitted model available | -32005 with the reason | an inline statement naming what is unavailable, never a substitution | outcome: "denied", reason: "policy.no_model" |
| Deadline exceeded | -32004 with deadlineMs | a progress indicator that resolves, then a statement that the step timed out | outcome: "timedout", latency, partial usage if billed |
Reading down the middle column, the distinction that matters is between endings the user needs at the moment and endings they need to be able to find afterwards. A policy denial for a server the user never explicitly enabled is noise in the conversation and belongs in an activity log; a budget exhaustion that will change the quality of everything this integration does for the rest of the day is not noise and belongs in front of them, once, with an action attached.
The stall is the ending most often left out of that table, because it is not a decision anyone made. A sampling request forwarded to a provider while the user watches a spinner is a foreground wait the user cannot attribute to anything: the agent said it was working on their ticket, and it has been thirty seconds. The deadline from chapter 3, where the per-server contract is assembled, gives that wait a bound, and the interface has to give it a description, which means the pending state names the integration and the step — the triage integration is generating a summary — rather than showing an undifferentiated indicator. A wait a user can explain is tolerated for far longer than one they cannot, and when it does resolve into a timeout, the statement that follows makes sense because they already knew what was being waited on.
The dashed edges are the ones this chapter exists to prevent. Each of them turns a decision into a delay before a different decision, and each is reachable from ordinary, well-meant code.
Raising a limit is an action, not a switch
"User-visible" is easy to satisfy in a way that satisfies nobody, with a toast that appears for four seconds while the user is reading something else. The inline statement is better because it is attached to the output whose quality it explains, and it survives scrollback: a summary that arrives with a line saying it was produced without access to session context, or that a triage integration hit its daily limit at 14:30, is a summary the reader can discount appropriately.
The affordance next to it needs to grant something bounded. An "allow anyway" button that flips the policy for that server permanently converts a control into a speed bump within a week, because the button is always in front of a user who wants their answer now. Granting an increment — another $5 for this server today, or ten more requests in this session — keeps the decision cheap to make and expensive to abuse, and each grant is itself a record with a user attached, which is how you later discover that one integration is generating fifty consent grants a day and needs a different budget rather than more clicking.
type Grant = { serverId: string; addUsd: number; expiresAt: string; grantedBy: string };
function offerRaise(record: SamplingRecord): Grant | null {
if (record.reason !== "policy.budget_exhausted") return null; // never for denials
return {
serverId: record.serverId,
addUsd: 5, // fixed increment, not "unlimited"
expiresAt: endOfDay(), // never outlives the budget window
grantedBy: currentUser(), // attributable
};
}The guard on the first line matters more than the rest of the function. A budget is a quantity a user may reasonably decide to spend more of. A policy denial, a user denial and an unavailable model are not quantities, and offering to override them from the same button teaches everyone that every refusal is negotiable.
What makes an increment a control rather than a formality is that its size is chosen against the traffic it has to survive. Five dollars, at the couple of cents a triage-sized completion costs, buys something on the order of two hundred and fifty further requests — enough that a user in the middle of a task finishes it, and nowhere near enough to feed a retry loop through an afternoon. The expiry does the other half of the work. A grant that ends with the budget window cannot accumulate, so a user who needs five dollars every afternoon produces a visible run of five-dollar grants rather than a limit that was quietly raised once, and that run is the evidence that the budget itself is wrong.
The four substitutions to design out
An unapproved model is the first, and the rule is that fallback may only select within the set the policy already permits for that server. Falling back from a large model to a small one inside the allowlist is a legitimate cost decision that the server sees in the result and the record captures; falling back outside it means the user's data went somewhere they did not agree to, which is a different event even when the model is better.
Widening the context scope is the same error on the axis chapter 2 covered, where the scope a server is given is treated as a ceiling to be lowered. It shows up as retry logic that reasons "the completion was poor, perhaps it lacked context", which is a sensible thought about a model and an unsafe thought about a boundary. Scope may be narrowed on retry and never widened.
Re-evaluating a repeated request is the one that looks like nothing at all. Without the request identity from chapter 4, a server that retries a denied request three times gets three evaluations, and evaluations are not deterministic across time — a budget window rolls, a rate limiter refills, a different user with a different policy picks up the session, a tired human clicks approve. The decision store makes the first answer the answer, which is what turns a denial into a decision rather than a coin flip the server may re-toss.
The fourth is subtler and appears in multi-server deployments: routing a denied request to a different server
that offers a similar tool. It preserves the user's outcome and quietly discards the reason the first server
was refused, which was usually about that server specifically. If a wiki server is denied sampling because
its context ceiling is none and it needs conversation context to be useful, sending the same work to a
server whose ceiling is conversation has not solved a problem, it has found the shortest path around one.
def test_no_silent_downgrade(client, server, policy):
policy.models = ["claude-haiku-4-5"]
policy.budget_usd = 0.0 # force the budget path
err = server.sample(messages=M, include_context="thisServer")
assert err["error"]["data"]["reason"] == "policy.budget_exhausted"
assert client.provider_calls == 0
assert client.models_used_this_session() <= {"claude-haiku-4-5"}
assert client.ui.inline_notices[-1].mentions(server.id, "daily limit")
again = server.sample(messages=M, include_context="allServers") # wider on retry
assert again["error"]["data"]["reason"] == "policy.budget_exhausted"
assert client.records.last().context_granted == "none"Where the client's obligations end
The client's obligations end at a correctly classified error frame, an accurate record and an honest
statement to the user. Whether the server degrades well is the server author's decision, and a badly written
one will return a stack trace, a half-finished result presented as complete, or nothing at all. Sending
-32003 with resetsAt gives that author everything needed to do better and does not compel it, which is
worth being clear-eyed about when evaluating a third-party integration: how it behaves when refused is a
property to test before enabling it, using the fixture server's counterpart — your own client refusing, and
the real server's response observed.
There is a belief here that reads as rigour and is not, and it needs naming because the protocol appears to
endorse it: the result names the model, so a substitution has been disclosed and the client has discharged its
duty. The disclosure is genuine. What it is not is a disclosure to the party carrying the cost. The model
field travels to the server, which is a program that will use whatever text it is handed, holds no
relationship with the person reading the summary, and has every incentive to return a result rather than an
apology for one. The person whose ticket was summarised on an unapproved model is the party who needed to
know, and no frame in the protocol is addressed to them. A signal whose recipient will not act on it, and
which never reaches the party it protects, is a record rather than a control.
Graceful degradation also is not always available. Some tools have no non-model code path, and for those the honest ending is a tool result that says what it could not do, surfaced as such, rather than a plausible output produced by other means. An agent that says "I could not summarise this ticket because the triage integration is over its daily limit" is more useful than one that quietly summarises it worse, and the second option only looks better in a demo.
A refusal is only a control once somebody can tell it happened. The server can always tell, because the protocol gives it a frame and the result names the model; the user can tell only if the client decided they should, and that decision has to be made ending by ending rather than once. The table above is the shape of that decision: a frame, a record, and a choice between an inline statement now and an activity-log entry to find later — with substitution absent from the options, because a substitution is what a system does when it has decided not to make the choice.
All of this machinery — the policy file, the error vocabulary, the decision store, the records, the adversarial suite, the fallback paths and the consent affordances — is the cost of having sampling turned on. It is a real cost, several weeks of engineering and an ongoing operational surface, and it is worth setting against what the feature actually buys for the servers asking for it, on a workload someone has measured rather than imagined.
Sampling — read the current version at https://daviddominguez.dev/learn/en/sampling-2/denial-paths-and-silent-downgrades