How the agent surface learns who is asking¶
Status: accepted, and nothing is built. This record takes a transport decision and states the evidence it rests on; no wiring, no crate change and no gate ships with it. What it decides is where a per-request principal can arrive on the agent surface, and it does that before any code because the alternative - attaching an exchange to a transport that has nowhere to put a caller - is the expensive way to find out.
0014 built leg 1 on the HTTP surface: a deployment
declaring security.inbound verifies a caller's own token from a signature.
0008 built the port and the broker for leg
2. Neither reaches the agent surface, and the reason is not an omission - it is the transport.
Where each identity claim is proven has said so in plain words
since before this record; what follows is the decision that closes it rather than a correction to a
false claim.
What is true today, measured rather than recalled¶
Every row below was read off origin/main at 5ae3bde on 2026-09-06, with
git cat-file blob origin/main:<path> and git grep -n <pattern> origin/main -- <path>. The
command is written here because a bare figure in prose is one nobody can re-check.
| Measured | Value |
|---|---|
occurrences of broker, case-insensitive, in the agent surface's composition root crates/sutura-cli/src/mcp.rs |
0. git cat-file blob origin/main:crates/sutura-cli/src/mcp.rs \| grep -c -i broker over a file grep -c '' reports at 471 lines |
crates/sutura-mcp/src/lib.rs:162 |
pub async fn serve_stdio<S>(service, permitted, prose, admission, reply) - no parameter a principal could arrive through |
| what that function opens | rmcp::transport::stdio() - the process's own pipes |
crates/sutura-mcp/Cargo.toml first-party dependencies |
sutura-app, sutura-config, sutura-domain, sutura-runtime. No adapter and no broker |
| the chain the agent surface establishes | crates/sutura-mcp/src/principal.rs: pub(crate) const fn established() -> RequestContext returning RequestContext::of(PrincipalChain::of(Subject::TheDeploymentItself)) - a const fn with no inputs, which is the honest shape for a transport that authenticates nobody |
| what capabilities that surface grants | crates/sutura-cli/src/mcp.rs:133 and :233 pass Permitted::every_capability(), fixed at construction |
| the same question on the HTTP surface | crates/sutura-http/src/capability.rs:138 pub fn permitted_for(request: &Request) -> Permitted, per request, from the verified caller's scopes |
Two things follow, and they are different. The first is that the pipe is the cause: a Permitted
fixed at construction and a const fn chain are both correct for a transport whose caller is
whoever launched the process. The second is that the command-line tool's
StaticCredentialBroker (crates/sutura-cli/src/sources.rs:147) is not a gap and must not be
reported as one - a single-user tool's operator is the subject, and an exchange there would be
impersonation of the person already at the keyboard.
The two shapes, and the one that was taken¶
- The agent surface over an authenticated HTTP transport, inside the server binary. Each request carries the caller's own bearer; the surface reads it per call and the existing broker exchanges it per subject.
- Keep the pipe and treat the launching identity as the subject. Honest for a single-user desktop agent, and effectively today's behaviour - but a process serving many people cannot be built out of it, at any amount of effort, because there is no per-request principal to start from.
Decision: shape 1, taken on telekom/sutura#378 on 2026-09-06 rather than derived in this
record - what this record does is carry the argument and the evidence. Shape 2 is rejected as the
answer for the served deployment. It is kept as the answer for the command-line tool, and that
retention is a decision rather than an absence: the two roots are separate binaries, and the tool's
pipe surface stays single-user on purpose.
The strongest argument against shape 1, recorded where the decision is taken¶
Every limit below has its own bullet in What this decision does NOT cover, and stating them one
at a time understates them, so the composite goes here: shape 1 produces a surface that reads as
multi-player before it is one. A deployment on it verifies a caller, names that caller in every
audit record, records which posture each leg ran under - and, because leg 2 has never run against a
real token service, may still read every row as one identity. That is .agents/skills/sutura/identity/SKILL.md's
own framing, and it is a sharper risk on the agent surface than on HTTP, because the reader is a
model summarising an answer rather than an operator reading a status line.
It is not a reason to choose shape 2, which cannot be multi-player at all. It is the reason
telekom/sutura#376 sits at step two of the ordering below rather than after the wiring, and the
reason the acceptance bar refuses rather than serves. The mitigation is the ordering and the
refusal; there is no mechanism that stops a deployment being stood up in between, and that is a
limit of this decision rather than an argument against it.
The load-bearing question, and its answer¶
The choice above is only available if the pinned SDK's HTTP server transport can carry an inbound header to a request handler. This repository's rule is to verify external behaviour rather than assert it, so what follows was read out of the vendored source of the pinned version on 2026-09-06.
Pinned: Cargo.toml:472 - rmcp = { version = "3.1", default-features = false, features = ["server", "transport-io"] },
resolving to 3.1.4 in Cargo.lock. No HTTP transport feature is enabled today, which is why
this is a question at all.
The server transports the pinned version offers¶
Read off the vendored src/transport.rs module declarations of rmcp 3.1.4, which is the list that
compiles rather than the list of files: sink_stream, async_rw, worker, child_process, io
(the pipes, today's choice), auth (client-side), streamable_http_client and
streamable_http_server. There is exactly one HTTP server transport - Streamable HTTP, whose
StreamableHttpService is behind #[cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))].
There is no server-side SSE transport in this version.
One file in that directory is not a transport and would be misread as one. src/transport/ws.rs
contains a single line - // Maybe we don't really need a ws implementation? - and transport.rs
declares it commented out:
Reading the directory listing rather than the module declarations would have counted a WebSocket transport this version does not have. That is the general trap: a file is not a module.
An inbound header does reach a handler, and this is the whole path¶
The service consumes the body and keeps the rest of the request. In
src/transport/streamable_http_server/tower.rs:
/// The service consumes the request body but injects the remaining
/// [`http::request::Parts`] into [`crate::model::Extensions`], which is
/// accessible through [`crate::service::RequestContext`].
That is documentation; the code is beside it, and it is done on every message rather than only on the handshake. Same file, the session path:
// inject request part to extensions
match &mut message {
ClientJsonRpcMessage::Request(req) => {
req.request.extensions_mut().insert(part);
}
ClientJsonRpcMessage::Notification(not) => {
not.notification.extensions_mut().insert(part);
}
_ => {
// skip
}
}
and the stateless path does the same thing:
let peer_info = Self::peer_info_for_stateless_request(&request, &parts.headers);
request.request.extensions_mut().insert(parts);
Those extensions become the handler's context. In src/service.rs:
pub struct RequestContext<R: ServiceRole> {
pub ct: CancellationToken,
pub id: RequestId,
pub meta: RequestMetaObject,
pub extensions: Extensions,
pub peer: Peer<R>,
}
filled, in the same file's serve loop, by moving the message's own extensions across:
let mut extensions = Extensions::new();
let mut meta = RequestMetaObject::new();
std::mem::swap(&mut meta, request.get_meta_mut());
std::mem::swap(&mut extensions, request.extensions_mut());
let context = RequestContext { ct: context_ct, id: id.clone(), peer: peer.clone(), meta, extensions };
and read by Extensions::get, in src/model/extension.rs:
crates/sutura-mcp/src/server.rs:305 already takes that value and discards it:
async fn call_tool(&self, request: CallToolRequestParams, _context: RequestContext<RoleServer>).
So the parameter the principal would arrive through already exists on the handler; under the
pipe it is empty, which is why it is bound to _context and why that binding is correct today.
Answer: yes. Under the pinned version's HTTP server transport, a handler can read
context.extensions.get::<http::request::Parts>() and reach parts.headers, per request, with no
macro and no SDK feature beyond the transport itself.
It composes with the router this repository already builds¶
crates/sutura-http/src/router.rs:180 returns a Result<Router, RouterNotBuilt> over axum's own
Router. The SDK's service is a
tower_service::Service:
impl<RequestBody, S, M> tower_service::Service<Request<RequestBody>> for StreamableHttpService<S, M> {
type Response = BoxResponse; // = http::Response<BoxBody<Bytes, Infallible>>
type Error = Infallible;
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
(BoxResponse from src/transport/common/server_side_http.rs:22), with a hand-written
unconditional Clone. axum 0.8.9's Router::nest_service
(axum-0.8.9/src/routing/mod.rs:235-240) asks for three bounds, and all three are named here
because omitting one is how a composition claim goes wrong:
T: Service<Request, Error = Infallible> + Clone + Send + Sync + 'static,
T::Response: IntoResponse, and T::Future: Send + 'static. The response bound is met by
axum-core 0.5.6's
impl<B> IntoResponse for Response<B> where B: http_body::Body<Data = Bytes> + Send + 'static, B::Error: Into<BoxError>,
which BoxBody<Bytes, Infallible> satisfies; the future is already BoxFuture<'static, _>. Sync
is the bound nothing in the SDK states, and it holds by construction rather than by assertion:
StreamableHttpService's fields (tower.rs:999-1014) are the Clone-derived
StreamableHttpServerConfig plus four Arcs, so the type is Sync whenever its S and M are.
Cargo.lock holds one http (1.5.0), one http-body (1.1.0) and one tower-service (0.3.3), all
already shared with axum - so this is a composition, not a type boundary in the sense
.agents/skills/sutura/dependencies/SKILL.md separates from a duplicate.
The limit on this whole subsection, stated beside the claim rather than left to the pull request
that carried it: every line above is a source read of the pinned artefact, not a build.
Nothing in this tree has been compiled against transport-streamable-http-server, so it composes
is a type-level argument from the two crates' own signatures and not a green just validate. The
first wiring change is where that becomes a measurement, and it may find something these signatures
do not show.
What turning the feature on costs¶
transport-streamable-http-server pulls three features, not two -
transport-streamable-http-server-session, server-side-http and transport-worker - which
between them name uuid, rand, tokio-stream, http, http-body, http-body-util, bytes,
sse-stream, base64, tower and async-trait. (transport-worker names only tokio-stream,
already in that list, so the closure is wider than it first reads and the package set is not.
tower in that list is the SDK's own FEATURE name, which enables dep:tower-service - it is not
the tower package.) Counted against Cargo.lock at 5ae3bde with
grep -c '^name = "<crate>"$': ten of the eleven already resolve; sse-stream does not, and
its own transitive tree is unmeasured here because measuring it needs a resolve. So the honest
statement of the cost is at least one new package, and an unknown tail below it.
It pulls no HTTP client: server-side-http names neither reqwest nor oauth2, and neither
resolves in Cargo.lock today. No gate holds that, and saying otherwise would be the defect
.agents/skills/sutura/invariants/SKILL.md warns about. cargo xtask check-shared-client is not
it: it reads exactly two facts, both about ureq - that Cargo.lock holds one version of it, and
that libduckdb-sys still depends on that version - because it exists to protect
0018's plus zero packages measurement. A
reqwest arriving here would not trip it. The no-client property above is a measurement of this
transport's feature closure on 2026-09-06 and is held by review. Whether adding sse-stream is
acceptable at all is a dependency decision under .agents/skills/sutura/dependencies/SKILL.md,
taken with the resolve in hand, not here.
What the SDK does not give, and this deployment must¶
rmcp 3.1.4 does know about WWW-Authenticate - in seven files, src/transport/auth.rs
among them - and every one of them is client-side: service/client.rs,
transport/auth.rs, transport/common/auth/streamable_http_client.rs,
transport/common/http_header.rs, transport/common/reqwest/streamable_http_client.rs,
transport/common/unix_socket.rs, transport/streamable_http_client.rs. Protected-resource
metadata is in transport/auth.rs alone, behind an auth feature pulling an OAuth client and an
HTTP client.
The sharper evidence is what the server transport can answer at all. Its complete status-code
set, read off src/transport/streamable_http_server/ and src/transport/common/server_side_http.rs
with grep -oh 'StatusCode::[A-Z_]*' | sort -u, is: ACCEPTED, BAD_REQUEST, FORBIDDEN,
INTERNAL_SERVER_ERROR, METHOD_NOT_ALLOWED, NOT_ACCEPTABLE, NOT_FOUND, OK,
PAYLOAD_TOO_LARGE, UNPROCESSABLE_ENTITY, UNSUPPORTED_MEDIA_TYPE. UNAUTHORIZED is not in
it. A transport that cannot spell 401 cannot issue the challenge that goes with it, so the 401,
the challenge and any discovery document are this repository's own work - which is the right side
of the line anyway, because what decides them is
0014's gate and not a transport's.
A second thing the SDK does not do, and it is the sharper one: it cannot record who opened a
session, structurally. SessionManager::create_session(&self)
(src/transport/streamable_http_server/session.rs:96-98) takes no arguments at all, so there
is no parameter an identity could arrive through; every later lookup is
has_session(&self, id: &SessionId) (:108), an existence test on the header and nothing more.
Grepping the whole server transport for bearer|authorization|principal|auth_ returns zero
hits, and for identity exactly two, both the type name TransportAdapterIdentity. So a later
request on an established Mcp-Session-Id may carry a different bearer, or none, and the SDK will
not notice.
The trap that follows, named because it is the one a later implementer falls into. In session
mode the handler is built once per session - service_factory (tower.rs:999-1014) called
through get_service (tower.rs:1107-1109) - so an identity stored as a field on the handler is
per-session by construction and will look correct in every single-caller test. The identity must
therefore be read per request from the parts, and a request whose parts carry no verified
caller must be refused rather than inheriting whoever opened the session.
never cached above is about the IDENTITY, and it decides nothing about the CREDENTIAL. The
two are different questions and conflating them would foreclose one of them by accident. Who is
asking is re-established from the request every time, because the transport gives no other honest
answer. What was minted for that subject is a separate decision, and it is open:
telekom/sutura#381 records that CredentialBroker::mint runs once per accepted question with no
cache, no pool and no per-subject reuse, which a chain multiplies to N sources by M hops on the
synchronous path of every answer. That issue's own invariant is the one to carry here: a cache
keyed on anything less than the whole verified input is a cross-subject leak - subject, audience,
scope and, for a chain, the entire ordered hop sequence, and never the session, the connection or
the process, which is exactly what the SDK's session blindness above makes unsafe. And the line
that does not move: the source is the backstop for a credential, and there is none for data, so
caching rows is not on the table at any point, and a cross-user leak never is.
What this decision does NOT cover¶
The list is the point of the record, and every entry is a thing a reader could otherwise take this decision to have settled.
- It does not deliver leg 2 on any surface. Leg 1 - knowing who is asking - is built on HTTP. Leg 2 - a source executing as them - is wired in the server binary and has never run against a real token service. This decision moves the agent surface onto a transport where leg 1 can reach it. It proves nothing about rows.
- It is not a claim that two callers get two answers. Nothing in the default suite asserts
that two subjects read two different row sets: the scaffold for it is
two_subjects_with_different_grants_read_two_different_row_setsatcrates/sutura-exec-bigquery/tests/acceptance.rs:768,#[ignore]d and needing a real project. This record changes none of that. - It does not decide the exchange chain. See the next section: one hop is built, and the chain the requirement asks for is a separate design with its own failure modes.
- It does not decide whether an exchanged credential may be reused. Re-establishing the
identity from each request is settled here; caching what was minted for that subject is
telekom/sutura#381and is untouched by this record. - It does not decide the dependency question. Enabling the transport feature adds at least one package to the lock. That is taken under the dependencies skill, with a resolve.
- It does not touch the catalog.
telekom/sutura#148- every token holder sees the whole bundle - is the metadata half of the same multi-player problem and is independent of every step here: no identity provider, no dataset and no round-trip to a cloud. Impersonation at the data layer does not narrow a catalog everyone reads whole. - It does not retire the pipe. The command-line tool keeps its single-user surface, with the launching identity as the subject, stated as a decision.
- It does not make anything published able to impersonate. No published artefact of either
binary links the dataset adapter (
nix/shipped.nix, and 0017's record of the feature being default-off), so the whole of this is a build-from-source capability until that trade is taken separately.
The exchange is a chain, and exactly one hop is built¶
The requirement this record serves is that the agent surface takes the caller's token from whatever enterprise identity provider issued it and reaches a credential the data source accepts, through however many exchanges that takes.
Measured at 5ae3bde: the broker performs one. crates/sutura-exec-bigquery/src/sts.rs:344:
let credential = self
.exchange
.exchange(workload.audience(), workload.scope(), assertion)
.map_err(|cause| ExchangeUnusable::Provider { cause: Box::new(cause) })?;
against the port at crates/sutura-exec-bigquery/src/sts.rs:111,
fn exchange(&self, audience: &str, scope: &str, subject_token: &Secret) -> Result<StsCredential, Self::Error>,
whose one non-test implementor is StsOverHttp at crates/sutura-exec-bigquery/src/wire/sts.rs:95.
There is no second hop and nowhere to configure one:
git grep -n -i 'audience\|scope' origin/main -- crates/sutura-config/src/credentials.rs returns
nothing. Nor is any enterprise provider wired -
git grep -n -i 'entra\|azure\|keycloak' origin/main -- crates/sutura-config/src crates/sutura-http/src
returns nothing.
Four things a chain changes that are not "loop N times", recorded now so they are not discovered later:
- Per-hop audience and scope, with a bad chain unrepresentable rather than rejected. One
WorkloadIdentitycarries one audience/scope pair. A chain needs an ordered sequence, and the configuration must make an out-of-order or incomplete sequence impossible to write, in the sense the newtype rules in.agents/skills/sutura/secure-by-design/SKILL.mdmean it - not a validation pass over aVecthat a later caller can skip. - A hop refuses; it never falls through. The single hop already answers
Minted::Refusedwhen the workload or the assertion is absent. Every hop must keep that, and a failed hop must never leave an earlier hop's credential presented - the shape where a caller is served under an intermediate identity is worse than a refusal and reads identically in a log that only records success. - The deadline and 0008 part 6's floor
fold across all hops. Today
Expiry::earliestfolds across sources andwith_floorrefuses a credential already inside the floor. With N hops the floor applies to the final credential, and an intermediate that would expire mid-chain has to refuse rather than be presented. That is a failure mode with no analogue in the current code. - Which hop failed has to reach the record.
ExchangeUnusable::Providerboxes one cause and names neither the hop nor the source. An operator cannot tell a provider outage from a misconfigured audience from that, and a chain multiplies the ways it is ambiguous.
A naming collision, stopped here on purpose¶
crates/sutura-http/src/identity_e2e.rs:109 and :127 call context.chain(), and
telekom/sutura#252 landed under the title the exchange chain, joined through the transport.
That chain is the identity context - who asked, and through which transport - and what #252
joined is transport → broker → exchange: the two halves of one hop. The same file's own module
documentation says what was missing was that "nothing joined the two halves of the exchange
chain".
It is not a multi-hop token chain, and a reader who takes chain() for N hops will be wrong about
how far this tree is. The word is load-bearing in two different senses in two adjacent places, and
this paragraph is the cheapest available fix.
The acceptance bar, and it does not soften¶
A request with no identity, or one whose exchange fails, must be refused - never served under
the process's own identity. Failing open there does not produce an error a caller can see; it
produces an answer, under the wrong identity, that reads exactly like a correct one. That is the
worst outcome available on this surface, and it is worse than the surface not existing. Fail-open
is not hypothetical in this repository either - the count of gates found failing open in the days
around this record, and its date, are in telekom/sutura#378's decision comment rather than
measured here.
Concretely, three cases and one answer each:
| Case | Answer |
|---|---|
| no verified caller on the request | refuse; never the deployment's own identity |
| a verified caller, exchange refused by the provider | refuse; never an earlier hop's credential and never the source's own |
| a verified caller, no workload declared for an impersonating source | refuse as credential_unavailable, which is the port's existing fallback |
Ordering, fixed¶
- The transport - this record. Not negotiable as step one: the pipe has no per-request principal, so no amount of exchange machinery makes the agent surface multi-user.
- Verify the existing single hop against a real token service (
telekom/sutura#376). A chain built on an unverified hop proves nothing. - An issuer-parameterised harness for an enterprise provider (
telekom/sutura#105), which is where a chain's first hop gets a real issuer to verify against. - The chain, with the four changes above designed rather than grown.
Beside step 4 rather than after it: telekom/sutura#381, the credential cache. A chain turns
one exchange per question into N sources by M hops on the synchronous path of every answer, so the
cache stops being an optimisation and becomes part of the chain's design - which is why it is not a
fifth step. Nothing in this record asserts a per-request cost, so #381 contradicts none of it; what
this record owes it is the boundary drawn above, that re-establishing the identity per request
decides nothing about reusing a credential.
telekom/sutura#148 sits outside that order entirely and is the cheapest real multi-player step
available, because it needs none of the four.
Alternatives considered¶
Keep the pipe and treat the launching identity as the subject. Rejected as the served answer, kept as the tool's. It is honest and it is free, and it has one property that ends the argument: a process serving many people cannot be built out of it. The value in having considered it is that it is already implemented, so the cost of choosing it was zero and it still lost - which is the strongest available statement that the transport is the constraint rather than the effort.
Carry the caller in a tool argument. Rejected on the same ground
0014 rejects it for the HTTP surface, and it is
structural here rather than a rule: sutura_domain::identity::RequestContext implements no
Deserialize, so a caller cannot state its own chain. A field on the wire type would be a caller
asserting an identity, which is the thing the whole design refuses.
Put the identity on the handshake and cache it per session. Rejected on the measurement above: the SDK binds no session to the identity that opened it, so a cached principal would be presented for a later request that carried a different bearer or none. Per request, from the parts, or not at all.
Wait for the chain, then do the transport. Rejected on ordering. Without a per-request principal there is nothing for a chain's first hop to consume, so building the chain first produces machinery whose input does not exist.