Chapter 3 of 6 · 15 min read
The blast radius of a misroute
On this page
The term is borrowed from ordnance and from incident response, and it is the right one because it is measured in effect rather than in probability. A tool call that restarts a public gateway has a blast radius of every request in flight for the duration of the restart. Nothing about the schema, the description, or the model's reasoning determines that; it is determined by what the operation does and by how much of it the caller was allowed to leave unspecified.
The borrowing is a metaphor and it fails in one place, which is worth naming because the place it fails is where the wrong design instinct comes from. A physical blast radius is a distance, and effect falls off with it: things near the centre are destroyed, things at the edge are shaken, things beyond it are fine. Nothing here falls off. A service that a misroute reaches is affected in full whether it is the neighbour of the intended target or unrelated to it, and there is no direction in which effect diminishes, because the operation does not travel through space — it is applied to whatever the arguments named. So the intuition to drop is keep the dangerous things far away, and the one to keep is that the reachable set is exactly the set of argument values the surface will accept. Bounding the radius means shrinking that set, which is what all three mechanisms below do.
Consequence and frequency are separate axes, and the previous chapter was entirely about the frequency one. Collapsing a surface and cutting the pairs it has to disambiguate takes selection accuracy up, and over 64 eval tasks even 93% leaves four or five wrong selections per pass. A perfect surface would still sit behind a human at two in the morning describing a symptom rather than an operation. The three mechanisms below assume the misroute happens and bound what it costs.
The four or five in that sentence is a multiplication rather than a guess, and it is worth doing because the result is what makes the rest of the chapter necessary. Accuracy of ninety-three per cent leaves seven per cent wrong, and seven per cent of sixty-four tasks is a little under four and a half — so four or five wrong selections every time the suite is run. Push the accuracy higher and the shape does not change: the count is a rate multiplied by a workload, and the workload in production is not sixty-four tasks a quarter, it is however many requests the agent handles. Halving the rate halves the count and does not reach zero, which is the sense in which frequency work has a floor. Everything below takes that count as given and asks what each of those calls is able to touch.
Ambiguity should land on a read
The most durable of the three is also the least clever: arrange the surface so that when two tools are plausible for a request, the plausible ones are reads. This is a design constraint on descriptions and names rather than a runtime check, and it works because the requests that produce ambiguity are recognisable in advance. They are the ones phrased as symptoms — "it's wedged", "something's wrong with checkout", "fix the payments API" — rather than as operations.
A tool described as "Restart a service that has become unresponsive" is reachable from "take a
look", because that sentence describes a condition and a remedy, so a model matching a described
condition against a tool list finds it on a request that never asked for a restart. A surface with
no tool whose description names a symptom has no such match available. deploy with
strategy: "restart" describes an operation on a target, get_logs and query_deployments
describe retrievals, and a symptom-shaped request has no write candidate to land on at all. The
model still has to pick something, and everything it can pick is a read.
There is a stronger version of the same idea that costs almost nothing to implement. One on-call assistant's traces over 30 days contained 4,100 tool calls, of which 3,850 were reads and 250 were writes, and the writes clustered in a handful of sessions where somebody was deliberately remediating. A separate read-only server, or the same server started with a credential that has no write scope, covers 94% of what that agent does. The diagnostic loop runs against it by default; the remediation loop is a different session, started deliberately, with the write surface attached. This is not a permission model — it is the observation that most agent work is investigation, and investigation does not need to be able to break anything.
Those three figures are one figure counted two ways, and checking that they close is what turns the traces into an argument. Three thousand eight hundred and fifty reads plus two hundred and fifty writes is four thousand one hundred, which is the whole month with no third category left over — no calls that were neither, no bucket quietly absorbing the awkward cases. Because the split is exhaustive, the read share can be read straight off it: three thousand eight hundred and fifty out of four thousand one hundred is a little under ninety-four per cent, which is the coverage figure. Had the two categories fallen short of the total, the leftover would be calls of unknown kind, and a read-only credential would be a bet on what they were. They do not fall short, so the ninety-four per cent is a measurement rather than an estimate, and the remaining six per cent is a known, enumerable set of write calls sitting in identifiable remediation sessions.
Naming the target instead of inheriting it
The second mechanism attacks the way a restart tool becomes dangerous, which is that it accepts a
service name and nothing else. The tempting convenience, and the one almost every server grows
because it makes demos shorter, is to let the destructive operation inherit context: environment
defaults to the currently selected one, service defaults to the last service the caller mentioned,
version defaults to latest. Each default removes an argument the model would otherwise have to get
right, which is exactly why they are dangerous — the argument it no longer has to get right is the
one that determines what gets hit.
# The convenient version. Two of the three fields that bound the effect are optional.
@server.tool()
def restart_service(service: str, environment: str = "current") -> str:
target = resolve(service, environment or session.current_environment)
orchestrator.restart(target)
return f"Restarted {target}"The replacement makes the target explicit and adds a precondition, which is the same trick a database uses for optimistic concurrency. The caller states what it believes to be true about the target, and the server refuses if the belief is stale or wrong.
@server.tool()
def deploy(
service: str, # exact id from query_deployments; no wildcards, no "all"
environment: Literal["dev", "staging", "prod"],
strategy: Literal["rolling", "restart", "recreate"],
expected_current_version: str, # required in prod; from query_deployments
) -> ToolResult:
target = registry.get(service, environment)
if target is None:
return error(
code="unknown_service",
message=f"No service '{service}' in {environment}.",
hint="Call query_deployments to list exact service ids.",
retryable=False,
)
if target.version != expected_current_version:
return error(
code="precondition_failed",
message=(
f"{service} in {environment} is at {target.version}, "
f"not {expected_current_version}."
),
hint="Re-read state with query_deployments, then retry with the current version.",
retryable=True,
)
return ok(orchestrator.deploy(target, strategy))The precondition does two jobs at once. It catches the misroute, since an agent that meant
payments-api-worker and typed payments-api will usually have read the worker's version a few turns
earlier and will send that, which no longer matches. It also catches staleness, which is a separate and
more common failure: the agent read the deployment state 40 seconds ago, a CI pipeline shipped in the
meantime, and the operation it planned is no longer the operation it would plan now. Both fail closed,
and both fail with something the caller can act on.
The refusal of wildcards belongs in the same section because it is the difference between a bounded
mistake and an unbounded one. A service field that accepts "*", a regex, or the literal "all"
turns one wrong selection into as many wrong effects as there are matches. If a fleet-wide operation
genuinely exists, it should be a separate tool with a separate name that says so, so that reaching it is
a decision rather than a character.
Confirmation only where the cost cannot be undone
The third mechanism is a human in the loop, and it is the one most often applied wrongly, because the obvious rule — confirm every write — destroys itself. A session that produces 40 approval prompts trains the approver to click through them; the approval latency on a team that instrumented this fell from a median of 12 seconds on the first three prompts of a session to under 1 second by the tenth, which is a person pattern-matching on the shape of a dialog rather than reading it. Confirmation is a scarce resource, and spending it on reversible operations means not having it for irreversible ones.
The latency figures are the measurement, so it is worth being clear about what they measure and why the collapse is more than a tenfold change in a number. Reading takes time. A dialog that names a service, an environment and a version cannot be understood in under a second, so a median that falls from twelve seconds to under one is not a person reading faster — it is a person who has stopped reading and is recognising the dialog's shape instead. Multiply it out and the reason is not mysterious: forty prompts at twelve seconds each is eight minutes of close attention inside one session, on top of the work the session is for, and nobody sustains that. The gate is still in the code after the tenth prompt. It is a keystroke. That is the specific way a confirmation mechanism fails, and it fails silently, because a clicked approval and a read approval are the same event in the log.
The gate belongs on irreversibility rather than on writes, which sorts the surface into three classes.
| Class | Examples | How it is undone | Gate |
|---|---|---|---|
| Reversible in place | scale, deploy with strategy: restart | Re-issue with the previous value, seconds | Precondition only |
| Reversible with cost | deploy of a new version, set_traffic | Roll back, 60–120 s of degraded service | Precondition, plus a dry run that resolves the operation and returns the diff without performing it |
| Irreversible | delete_environment, rotate_credentials, issue_refund | Not undoable; restore from backup or a support ticket | Two-phase confirmation |
Two-phase confirmation means the first call does not perform the operation. It returns a resolved description of what would happen and a token, and only a second call carrying that token executes.
The token is bound to a hash of the exact arguments and expires in 120 seconds, both for the same reason:
an approval is approval of one described operation, not a general permission. Without the argument
binding, an agent that gets a token for staging-eu and then decides the real target was prod-eu can
reuse it, which converts the confirmation into a formality. The summary carried alongside the token is
what the human actually reads, so it should contain resolved facts — 14 services, 2 databases, 840 GB —
rather than an echo of the arguments, since the whole point is to show the caller something it did not
already know.
The two properties of that token are not redundant, and seeing why takes one step each.
Suppose the token were bound to the arguments but never expired. The approval would then be a
standing permission to delete staging-eu, redeemable in a week, by which time the summary
the human read — fourteen services, two databases — may describe nothing that is still true.
Now suppose it expired but was not bound. Within the window, an agent could obtain approval
for one target and spend it on another, which is the retargeting case above. Each property
closes a hole the other leaves open: the binding fixes what was approved and the expiry
fixes when it was approved to happen. An approval is a statement about one operation at one
moment, and it takes both to say so.
Measuring consequence rather than frequency
These mechanisms do not move the selection accuracy number, and a team that expects them to will conclude they failed. Over the quarter after they landed, the deployment server logged 11 write misroutes on the eval set, essentially unchanged; 9 were stopped by a precondition or an unknown-service error before touching anything, 2 executed and were reversible in place, and none produced an externally visible incident. Frequency and consequence are separate axes, and progress on one is invisible in the metric for the other, which is a reason to record both rather than a reason to distrust either.
The outcome column in that sentence is the whole result, and it holds because it closes. Nine stopped before touching anything plus two that executed reversibly is eleven, which is every write misroute in the quarter, with no third line. That absence is the finding. A count that fell short of eleven would leave a residue of misroutes whose outcome nobody classified, and "none produced an externally visible incident" would then be a claim about the classified ones only. Because the two outcomes exhaust the total, the claim covers all of them. Read the two numbers together and they say something the accuracy metric structurally cannot: the frequency was unchanged and the distribution over outcomes moved entirely, from a set that included one service restarted in front of live traffic to a set in which the worst case was undone by re-issuing a command. That is what progress on the consequence axis looks like, and it is visible only if the outcome of every misroute is recorded rather than the count of them.
There is a dimension of consequence that no count of misroutes reaches, and the incident above is where it shows. The restart lasted well under a minute; the deliveries it rejected worked out at around thirty a second across that window, which on its own is an unremarkable number. What set the cost was the multiplier, and the multiplier belonged to a system nobody on that team operates. A processor that retries on an eight-hour cycle and abandons a delivery after three attempts puts its own give-up point roughly a day out from the failure, and everything it abandons becomes reconciliation somebody does by hand. So the duration of a blast radius is often a property of the systems downstream of yours rather than of your outage. Which of them retry, on what interval, and when they stop are worth knowing in advance, because that term is the one you cannot shorten once the call has gone out.
Authorization, the credential, and the unbounded read
None of this is authorization, which matters because the behavioural annotations covered in topic 6.4, on tools over MCP, are advisory. A tool marked with a non-destructive hint is still able to delete things if its implementation deletes things; the hint tells a client how to present the call and binds nothing. Every check in this chapter runs on the server, inside the handler, after the arguments arrive, because that is the only place where a check cannot be skipped by a client that chose not to implement it.
The deeper limit is that blast radius is ultimately bounded by the credential, not by the tool set. A
confirmation gate on delete_environment accomplishes nothing if the same service account can reach the
same effect through deploy with a strategy that recreates, or through an exec tool that some other
server exposes to the same agent. Scoping the token to the environment and the operations the surface
actually offers is the floor under all of this, and the tool-level mechanisms sit on top of that floor
rather than replacing it.
There is one category these mechanisms genuinely do not touch, which is the read that should not have
happened. A misroute onto query_issues with an unbounded filter can return a customer table into a
context window that then goes somewhere else, and nothing about read/write separation helps, because
separation assumed reads were the safe landing place. Bounding what a read can return — row limits, field
allowlists, tenant scoping enforced in the query rather than in the schema — is the same discipline applied
to a different kind of damage, and it is less well covered by convention than the write side is.
Every mechanism here produces a refusal: a precondition that failed, a service that does not exist, a
confirmation that has not been given. Each of those is a moment where the server has stopped an agent that
believed it was doing the right thing, and what happens next depends entirely on whether the refusal tells
it what to do instead. A precondition failure that comes back as {"error": "operation failed"} produces
an agent that tries again with the same arguments, and then again, which is the loop these mechanisms
existed to prevent, arriving through the front door.
Building a server — read the current version at https://daviddominguez.dev/learn/en/building-a-server/the-blast-radius-of-a-misroute