← All articles Agent infra

An agent should know its blast radius up front

20 August 2026 · 6 min read

A credential does not announce its own scope. The model holding one will assume a scope anyway.

Here is the first response from a session that opened this morning. It is the reply to an identity call, the equivalent of whoami, made before any other work:

{ "name": "OBTO Support", "domain": "ogpss", "identityDomain": "dev", "isSuperUser": true, "crossTenantEnabled": true, "tenantDomains": { "count": 67 }, "clusterLabel": "ogpss" }

Four of those fields are ordinary. The three highlighted ones are the interesting part, because they are the only place in the entire session where the reach of that credential is written down. Nothing in the shape of an API key says whether it addresses one tenant or several hundred. Nothing in the shape of a tool call says so either. If the identity response does not state it, the number does not exist anywhere the model can reach.

The scope a model assumes by default

Absent a statement, a model infers its scope from whatever is in front of it. That connection stamps domain: "ogpss" as its default, so the natural working assumption is that the session operates on ogpss. The assumption is wrong, and the platform says so in the same payload:

"'ogpss' is only this connection's vhost default, NOT a boundary. Your identity domain is 'dev', so you may pass ANY active tenant's domain per call."

This is a field that looks like a fence and holds nothing. It is the value the connection volunteers about itself, and it constrains no operation whatsoever. A model reading it has every reason to treat it as a limit and no way to discover that it is a default.

The gap between those two readings is one parameter. An agent that believes it is confined to one tenant, and is in fact able to address sixty-seven of them, is not misbehaving when it writes to the wrong one. It is acting correctly on the only information it was given.

Fields that look like discriminators and are not

The same problem appears one level up, at the question of which environment you are connected to. The identity response answers it directly:

"mcpUrl": "https://app.obto.co/ms/mcp", "mcpUrlNote": "Universal control-plane endpoint, identical on every cluster. To tell connections apart use domain / identityDomain / clusterLabel, never this URL."

A URL is an attractive thing to reason from. It is always present, it is stable, and it looks like an address. It is also identical across every environment, which makes it the most available field in the response and the least informative one. That combination is what produces a confident wrong answer rather than an obvious failure, and a confident wrong answer about which environment you are in is expensive.

Documenting which of your own fields a caller must not reason from costs one line, and it closes an inference the model would otherwise make silently.

Declare the refusals before they happen

The same call, answered for a second credential on the same platform, names an operation that will refuse:

"OPERATOR IDENTITY: your home domain 'dev' is the platform-operations domain. obto_scaffold_app refuses 'dev' without confirmOperatorDomain: true (logged)."

The refusal exists either way. The question is when the agent learns about it. Discovered by hitting it, a refusal costs a round trip, a failed tool call in the transcript, and a model that now has to work out whether the operation was partially applied. Declared at the moment the agent learns who it is, the same refusal costs one line and shapes every plan the model makes afterwards.

The two halves belong together. A good error payload repairs a call that has already failed. An identity payload prevents the class of call that would fail. Neither substitutes for the other.

Why the layer above cannot supply this

An agent framework can add a line to the system prompt: you are operating on tenant X. That line describes the scope the author intended. Whether it is the scope that holds is a fact about the authorisation layer on the far side of the API, and the framework has no read on it. The two copies drift, the prompt is the copy the model trusts, and prompt-level constraints are advisory in exactly the way prompt-level guardrails are.

The distinction is sharper than it sounds. Consider a read where the caller supplies a tenant filter that contradicts the one it is allowed to use:

// requested: { "domain": "ogpss", "name": "blog" } { "ok": true, "tenancyFilterApplied": { "domain": "www", "name": "blog" }, "returned": 1, "docs": [ { "app": "ob_www", "domain": "www", "name": "blog" } ] }

The requested filter named one tenant. The executed filter named another. The server did not reject the request, it rewrote it, and then reported the rewrite in the response so the caller could see the difference between what it asked for and what ran. That reporting is only possible from the position that performs the rewrite. A wrapper never holds the executed query; it holds the one it sent, which in this case is the wrong one. The same mechanism that scopes every query before it runs is what makes the echo truthful.

So a framework can report intent and observe outcomes. Enumerating authority before the first call requires standing where the authority is enforced.

What to return from your own identity call

Four things, in rough order of value:

None of this requires new infrastructure. The reach, the defaults, the cluster label and the confirmation flags are all resolved at the moment the session opens, which is the moment the identity call is answered. This matters more under a stateless contract, where every call carries its own scope rather than inheriting one, because the identity response is then the only place the whole picture appears at once.

If you own an identity endpoint and a model might call it, those four fields are an afternoon in the handler, and they get read at the start of every session for as long as the API exists.

Frequently asked questions

What should an agent's identity call return?

At minimum: the reach of the credential expressed as a quantity rather than a role label, an explicit note on any field that resembles a scope boundary without being one, an explicit note on any field that resembles an environment discriminator without being one, and the list of operations that will refuse without an additional confirmation flag. A model builds its working assumptions from this response, so anything left unstated becomes a guess.

Why not just put the agent's permissions in the system prompt?

A prompt statement describes the scope the author believed applied when the prompt was written. The scope that actually holds is a property of the authorisation layer and can change without the prompt changing. The two drift silently, and the prompt is the copy the model trusts. Returning authority as data from the enforcing system removes the second copy.

What is a non-boundary field, and why does it matter?

A field that appears on every response and looks like a scope, but constrains nothing. A connection whose default tenant is stamped on each reply teaches a model that it is confined to that tenant. If the credential can address other tenants by passing a different value, the model is one parameter away from an action it believed was impossible. Saying so in the identity payload costs one line.

Can an agent framework report a credential's real reach?

It can report the scope it requested. Whether that scope held is a fact held by the system enforcing it, on the other side of the API boundary. A framework observes its own intent and the eventual responses, which is enough to detect a refusal after the fact and not enough to enumerate authority before the first call.

Does returning the tenant count leak information?

A count is a different disclosure from a list. Returning the number of tenants a credential can address tells the holder how much authority it has without naming anyone. Where a full list is returned it should be the same set the caller is already entitled to read, generated through the same authorisation path as a normal read rather than queried directly from the underlying store.

More from the OBTO blog