Skip to content
Chapter 7 of 8 · SamplingDenial paths and silent downgrades

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

EndingFrame to the serverWhat the user seesWhat is recorded
Denied by policy-32001, retryable: falsenothing inline if the tool degrades cleanly; an entry in the session's activity log naming the server and reasonoutcome: "denied", reason, zero cost
Denied by the user-32001, retryable: false, distinct reasonthe dialog they just dismissed, plus a durable note that this server was denied for the sessionreason: "user.denied", the identity, so it is not asked again
Over budget-32003 with resetsAtan inline statement in the agent's output: this integration has reached its daily limit, with an affordance to raise itoutcome: "denied", reason: "policy.budget_exhausted", spend to date
No permitted model available-32005 with the reasonan inline statement naming what is unavailable, never a substitutionoutcome: "denied", reason: "policy.no_model"
Deadline exceeded-32004 with deadlineMsa progress indicator that resolves, then a statement that the step timed outoutcome: "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.

Flowchart: sampling request → authorize; authorize → call permitted model (allow); authorize → error frame + record (deny); call permitted model → result to server; error frame + record → activity log entry; error frame + record → user-facing?; user-facing? → inline statement + bounded raise (budget, no model); user-facing? → activity log entry (policy, user denial); error frame + record → different model (prohibited); error frame + record → wider context (prohibited); error frame + record → re-evaluate on retry (prohibited).

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.

ts
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.

python
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

Back to top