Chapter 4 of 8 · 12 min read
Denial frames and request identity
On this page
An authorization decision that stays inside your process is not a control, it is a pause. JSON-RPC gives you
exactly one shape for communicating it: a response to a request is either a result or an error, and the
error object carries an integer code, a human message, and an optional data member that may hold
anything you like. The codes from -32700 to -32600 and -32603 are reserved by the protocol for parse, request
and internal failures; the range from -32000 to -32099 is reserved for implementation-defined errors, which
is where an application-level vocabulary belongs.
Sending that frame closes the request. It does not close the question, because nothing stops the server from sending the same request again under a new envelope, which is why a refusal needs two things rather than one: an error vocabulary the server can branch on, and a notion of request identity that lets your side recognise a request it has already refused.
An error frame the server can branch on
{
"jsonrpc": "2.0",
"id": 47,
"error": {
"code": -32003,
"message": "Sampling denied: 24h budget for server 'release-notes' exhausted",
"data": {
"reason": "policy.budget_exhausted",
"server": "release-notes",
"retryable": false,
"resetsAt": "2026-03-11T00:00:00Z"
}
}
}Silence is the worst of the available frames because of what the other side is forced to infer from it. A server SDK waiting on a request it sent cannot distinguish a client that decided against it from a client that crashed, a transport that dropped the frame, or a laptop that went to sleep, and the only universally safe response to an ambiguous non-answer is to try again. Every retry policy anyone ships is built on that assumption. Dropping the frame therefore does not merely fail to inform the server, it actively instructs the server to repeat itself.
The reason string in data is the field a server should branch on, because it is stable, machine-readable
and does not change when someone rewrites the message for the user. Putting the classification only in the
English text forces every server author to pattern-match on your prose, which they will do, and which will
break the first time you improve the wording.
Four outcomes need to be distinguishable, and they call for four different behaviours on the far side.
| Outcome | Frame | retryable | What a well-behaved server does |
|---|---|---|---|
| Denied by policy | error, -32001 (sampling not permitted) or -32003 (budget) | false | stop asking for this session; fall back to a non-model code path or return a partial result explaining what is missing |
| Throttled | error, -32002, with retryAfterMs in data | true | wait the stated interval, retry at most once, then degrade |
| Context trimmed | result, with a _meta note naming the scope actually applied | n/a | decide whether the answer is usable given what it did not see; say so in its own output if not |
| Deadline exceeded | error, -32004, with deadlineMs in data | true | retry once with a smaller request, or degrade; never retry unchanged |
The third row is the one that surprises people. Trimming is not a failure — a completion was produced and it
is being returned — so encoding it as an error throws away work that was paid for. What the server needs to
know is that the context it asked for is not the context it got, which is a property of a successful response
and belongs in the response's metadata. A summarisation server that requested allServers, received a
completion generated with no context at all, and was told so, can put "summary generated without access to
session context" in its output. The same server told nothing will present a confident summary of material it
never saw, and the user has no way to know.
Annotating a trimmed response costs one member on the result. Attaching
"_meta": {"context": {"requested": "allServers", "applied": "none", "reason": "policy.max_scope"}}
alongside content, model and stopReason keeps the response valid for a server that ignores it and
informative for one that does not, and it gives your own audit trail a record of the gap between what was
asked and what was granted. That gap is the single most useful number to watch when deciding whether a
server's integration is working as its author expected: a server whose requests are trimmed on every call is
either producing degraded output nobody has noticed or asking for context it never needed.
The distinction between false and true in the retryable column is worth being strict about, because it
is the only signal keeping a retry loop from forming. A budget denial that a server retries is not a bug the
server can be blamed for if the frame it received looked transient. Naming the outcome precisely is what
makes deliberate degradation possible, and deliberate degradation on the server's side is cheaper for
everyone than the alternative your policy will otherwise have to absorb.
Getting that flag wrong is not symmetric, and knowing which direction is cheaper is what tells you where to
land when a new reason does not obviously belong in either column. Marking a permanent refusal retryable
produces a loop: the server waits, asks again, receives the same permanent refusal, and the only thing
bounding the repetition is whatever retry budget its author happened to write. Marking a transient throttle
non-retryable produces the opposite failure, a server that abandons a request it would have been allowed to
make a second later, and what that costs is one degraded tool result. The first failure is unbounded and
consumes your rate limiter, your logs and your budget; the second is bounded, and it is visible to a user as
a single worse answer rather than as a fleet-wide cost. So false is the safer default under uncertainty,
because a server that has stopped asking can be fixed by a person and a server that cannot stop cannot.
The decision has to outlive the answer
The JSON-RPC id is a correlation token chosen by the sender, unique only within the connection, and
incremented on every send. Request 48 carrying byte-identical params to request 47 is, at the protocol
level, a new request; at the policy level it is the same request re-presented, and a client that evaluates it
from scratch will show the user a second consent dialog, count it a second time against the rate limiter,
and — if the user is tired or the budget window has just rolled — eventually answer it.
Payment APIs solved a version of this with client-supplied idempotency keys, where the caller commits to a key and the server binds the outcome to it. Sampling gives you no such key and you would not want one from the server anyway, since the server is the party whose repetition you are trying to detect. The identity has to be derived by the client from the content of the request, as a digest: a short fixed-length fingerprint of the bytes, such that identical requests produce identical digests and any change at all produces a different one.
IDENTITY_FIELDS = ("systemPrompt", "messages", "includeContext", "modelPreferences")
def request_identity(server_id: str, params: dict) -> str:
# Canonical JSON: sorted keys, no insignificant whitespace, NFC-normalised text.
payload = {k: params.get(k) for k in IDENTITY_FIELDS}
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"),
ensure_ascii=False)
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
return f"{server_id}:{digest[:32]}"The choice of which fields go in is a policy statement rather than a hashing detail. systemPrompt and
messages are the prompt, includeContext is what the server is asking to see, and modelPreferences is
what it is asking to run on; together those are the request as a human reviewer would describe it.
maxTokens, temperature and stopSequences are deliberately excluded, because they are knobs a server can
nudge without changing what it is asking for, and a temperature moved from 0.2 to 0.2000001 should not
produce a request your client believes it has never seen. The trade is explicit: excluding them means a
server genuinely retrying with a smaller maxTokens after a deadline error hits the stored decision, which
is why deadline outcomes are stored with a short expiry rather than a session-long one.
Two properties make that digest usable as a decision key, and both are choices rather than free consequences of hashing. The first is canonicalisation. A JSON object has no single byte representation: the same fields in a different order, or with a space after each colon, or with an accented character written as an escape by one encoder and as a literal by another, all mean the same thing and hash to different values. Sorting the keys, dropping insignificant whitespace and normalising the text collapses those variants onto one string, so two requests a human would call identical produce one digest rather than several. Skipping that step fails open and fails quietly, because every honest retry that passed through a differently configured serialiser looks new, and the test suite does not notice — its two requests came out of the same encoder. The second property is that truncating the digest is safe: thirty-two hexadecimal characters carry one hundred and twenty-eight bits, and against a store holding at most a few thousand entries in a session, two different requests colliding is not something anyone needs to reason about.
DECISION_TTL = {
"policy.sampling_disabled": None, # session lifetime; nothing will change it
"policy.budget_exhausted": None, # until the window resets
"policy.input_too_large": None,
"user.denied": None,
"policy.rate_limit": 60, # seconds; genuinely transient
"policy.deadline": 30,
}
def evaluate(req, pol, state) -> Decision:
ident = request_identity(req.server_id, req.params)
prior = state.decisions.get(ident)
if prior is not None and not prior.expired():
prior.represented += 1 # feeds the alert, not the answer
return prior.decision # replayed without consent, model or spend
decision = authorize(req, pol, state)
state.decisions.put(ident, decision, ttl=DECISION_TTL.get(decision.reason))
return decisionThe represented counter is the part that pays for itself twice. It suppresses the duplicate work, and it
turns a server's retry behaviour into a number: a request identity re-presented forty times in ten minutes is
a retry loop, and it is visible without anyone reading a log.
Every terminal edge in that machine sends a frame. There is no path where the server learns nothing.
Where that store lives decides how much of the property survives contact with reality. Holding it in memory
on the connection is the obvious implementation and it evaporates the moment the connection does, which
matters because a server that dislikes an answer has a cheap way to reset the store: disconnect and
reconnect. Local stdio servers restart in well under a second, so a session-scoped decision store gives a
persistent server roughly as many fresh evaluations as it cares to ask for. Keying the store on
(server_id, identity) in the client's own durable storage, with the same TTLs — the time-to-live after
which a stored decision expires and the request may be evaluated afresh — closes that loop and has the
additional benefit that a user who denied a request on Monday is not asked about it again on Tuesday. The
cost is a small amount of state to expire and a rule about when a genuine policy change should invalidate
stored denials, which is normally "on any edit to that server's policy block".
The size of that hole is worth quantifying rather than leaving as a worry, because the quantity is what makes the storage decision obvious. A local stdio server restarts in well under a second, so a store scoped to the connection hands a persistent server thousands of fresh evaluations an hour, each carrying its own consent dialog and its own chance of reaching a user who is not reading carefully. The same store keyed durably hands it one. There is no setting between those two outcomes, because the store either outlives the transport or it does not, which is why this is a choice about where state lives rather than a parameter to tune.
A test can then assert the property directly rather than by inspection.
def test_retry_cannot_bypass_denial(client, adversary):
first = adversary.sample(system_prompt=P, messages=M, include_context="allServers")
assert first.error["data"]["reason"] == "policy.budget_exhausted"
second = adversary.sample(system_prompt=P, messages=M, include_context="allServers")
assert second.error["data"] == first.error["data"]
assert client.consent_prompts_shown == 1 # the user was asked once
assert client.provider_calls == 0 # nothing was spent on either
assert client.decision_store.represented(first_identity) == 1What a stored decision holds
It is not a response cache. Storing the decision and storing the completion are different features with
different risks: replaying a stored completion means returning text generated against context that may have
changed since, which is a correctness bug that looks like a performance win. The store here holds a verdict
and a reason, never a result.
It is also not a defence against an adversary. A server that wants a second evaluation only has to change one character of its prompt, and the digest changes with it, so identity catches honest retries, buggy retry loops and lazily repeated requests, and catches nothing that is trying. The bound on a server that iterates is the per-server rate limit and budget from the previous chapter, which count requests regardless of what is in them. Identity and volume controls are complementary in a specific way: identity makes repetition free to refuse, volume controls make variation expensive to attempt.
The last thing it does not do is make the record complete. A decision store keyed by identity answers whether this request was allowed, and it answers that within one session, in memory, for as long as the connection lives. It says nothing about which model ran, how many tokens it consumed, what it cost, how long it took, or what context went with it.
The two halves of this chapter are the same property viewed from either end of the connection. From the
server's side, a decision it can read is what makes deliberate degradation possible: a -32003 with a
resetsAt produces a tool result that explains itself, where a dropped frame produces a retry loop and a
timeout message about the network. From your side, a decision bound to an identity is what stops a refusal
from decaying into a coin flip that the server may keep tossing — the answer is given once, replayed for
free, and counted.
What neither half produces is a record that outlives the process. Which model ran, how many tokens it consumed, what it cost, how long it took and what context went with it are the fields that turn a scatter of local decisions into something you can query a month later, when the only evidence anyone brings you is a bill that has tripled.
Sampling — read the current version at https://daviddominguez.dev/learn/en/sampling-2/denial-frames-and-request-identity