A2A call with delegation#
Agents call other agents over agent-to-agent (A2A) v1.0, and MAQPNA puts those calls through the same gateway as tool calls. Every A2A message is authenticated, checked against the kill switch for the whole delegation chain, scoped, allow-listed, depth- and loop-limited, scanned by data loss prevention (DLP), evaluated by policy, budgeted, optionally held for approval, and recorded twice in the audit ledger: once for the caller and once for the callee.
There are two kinds of target, both reached at /a2a/<namespace>/<name>:
| Target | Declared by | Trust default | Identity sent upstream |
|---|---|---|---|
| Hosted agent | Agent.spec.a2a with expose: true and endpoint (its in-cluster A2A JSON-RPC URL) |
trusted |
X-Maqpna-* headers and, with txnTokens, a Txn-Token |
| Remote peer | A2APeer with url, pinned card keys and allowedCallers |
untrusted |
None: authenticated through auth (none or tokenExchange) |
The operator renders both into the ConfigMap maqpna-a2a (key a2a.json). Peers or agents that break the sovereignty policy in enforce mode are not published.
The call#
sequenceDiagram
autonumber
participant A as Agent A (planner)<br/>session s-1, user alice
participant GW as Gateway
participant B as Agent B (coder, hosted)
participant L as Audit ledger
A->>GW: POST /a2a/team-a/coder SendMessage<br/>Bearer A's token (scope agents:coder)
GW->>GW: verify token, kill switch for A and its act chain,<br/>callee not revoked
GW->>GW: scope, allowedCallers, depth, loop, skill
GW->>GW: DLP on params, policy (server a2a/coder, tool SendMessage),<br/>pins, budgets, approval
GW->>GW: mint Txn-Token (tctx.target team-a/coder)
GW->>B: JSON-RPC SendMessage + X-Maqpna-* + Txn-Token
B-->>GW: result
GW->>GW: DLP on the result, meter
GW->>L: caller record: server a2a/coder, ext.a2aHop outbound
GW->>L: callee record: server a2a/inbound/team-a/planner, ext.a2aHop inbound
GW-->>A: result
The gateway accepts the JSON-RPC binding (POST /a2a/{ns}/{agent}, single requests only) and the HTTP+JSON binding (/a2a/{ns}/{agent}/message:send, tasks/{id}, ...). It always speaks JSON-RPC upstream, mapping the HTTP+JSON binding onto the equivalent method and back.
Governance order#
flowchart TD
V["1 Verify token (+ SVID binding)"] --> K["2 Kill switch: caller, every agent<br/>of its delegation chain"]
K --> CR{"3 Callee revoked?"}
CR -- yes --> D1["deny callee_revoked"]
CR -- no --> S{"4 Scope agents:name<br/>or agents:ns/name?"}
S -- no --> D2["deny missing_scope"]
S -- yes --> AC{"5 Caller in allowedCallers?<br/>(empty = target's namespace)"}
AC -- no --> D3["deny caller_not_allowed"]
AC -- yes --> DD{"6 Chain deeper than<br/>maxDelegationDepth (5)?"}
DD -- yes --> D4["deny delegation_depth"]
DD -- no --> LP{"7 Callee already in the chain?"}
LP -- yes --> D5["deny delegation_loop"]
LP -- no --> SK{"8 Skill declared or allow-listed?"}
SK -- no --> D6["deny unknown_skill"]
SK -- yes --> PC{"9 Remote peer: card signed<br/>by a pinned key?"}
PC -- no --> D7["deny peer_card_unverified"]
PC -- "yes, or hosted" --> G["10 DLP, policy, pins, budgets,<br/>approvals (same as a tool call)"]
G --> F["11 Forward, DLP on the result,<br/>audit both hops,<br/>taint caller if target untrusted"]
- Authenticate the caller's token, and bind its workload certificate when
tls.svidModeis set. - Kill switch for the caller and for every agent in its
actchain, so revoking any agent upstream of a delegation stops it here. - Callee revoked? A revoked target answers
callee_revoked. - Scope. The token must hold
agents:<name>(same namespace) oragents:<ns>/<name>(across namespaces).tools:*never grants it. - Allowed callers. The target's
allowedCallers(<agent>or<ns>/<agent>globs); empty means the target's own namespace only. - Depth. A delegation chain longer than
a2a.maxDelegationDepth(default 5) is refused. - Loop. A call back into a hosted agent already in the chain is refused.
- Skill. The skill comes from
message.metadata.skillIdorparams.metadata.skillId(a MAQPNA convention; A2A has no standard selector). It must be declared by the hosted agent, or be on the peer's verified card and itsskillsallow-list. - Peer card. For remote peers the gateway fetches the agent card (residency-checked, cached
a2a.cardCacheSeconds), verifies its JWS signature over the RFC 8785 canonical form with a pinned key (cardKeys), and checks that it lists the peer's URL.requireSignedCarddefaults to true. - The tool-call path. DLP on
params, then policy with servera2a/<agent>(a2a/<ns>/<agent>across namespaces) and toolSendMessage(also forSendStreamingMessage),skill:<id>or the method name; tool pins; session and hierarchical budgets; approvals (X-Maqpna-Async,X-Maqpna-Approval-Id, four-eyes). - Forward and record. Hosted callees get the caller's identity headers and a
Txn-Token; remote peers get neither. DLP runs on the result, spend is metered, and two records are written: the caller's (servera2a/<agent>,ext.a2aHop=outbound) and, for hosted agents, the callee's (servera2a/inbound/<callerNs>/<callerAgent>,ext.a2aHop=inbound,ext.a2aCaller= the caller's SPIFFE ID).ext.a2aCallcorrelates them. If the target isuntrusted(all peers by default), the caller's session is tainteduntrusted-input.
Delegation: the callee acts for the caller#
When B handles A's message and needs to call its own tools on A's behalf (so policy still sees alice as the user), B exchanges the call's Txn-Token plus its own session token at the identity broker.
sequenceDiagram
autonumber
participant B as Agent B
participant IB as Identity broker
participant GW as Gateway
participant T as MCP server
B->>IB: POST /v1/exchange subject_token=Txn-Token<br/>subject_token_type=...:txn_token, actor_token=B's token
IB-->>B: token: sub B, act {sub A's SPIFFE ID, act {sub user:alice}},<br/>scopes narrowed to B's, exp at most either token
B->>GW: tools/call with the delegated token
GW->>GW: kill switch for B and A, principals match alice,<br/>audit ext.actChain = A's SPIFFE ID,user:alice
GW->>T: forward
- The broker accepts the Txn-Token only if it was signed by its own keys or
-txn-token-keys, its audience is the trust domain (or-txn-token-audience), andtctx.targetis B. - The actor token must be a session token, not an exchanged or delegated one.
- Depth is capped by
-max-delegation-depth(default 5); self-delegation and tenant crossings are refused (the delegation token is signed with the actor's tenant key). - The new token nests the chain (outermost = immediate delegator), keeps the user's groups and verification, and carries
maqpna_delegator_jti. - At the gateway,
Claims.User()walks the chain to theuser:entry, so policy principals keep matching the human, and every record carriesext.actChain.
What you see#
A refused call reaches the caller as a JSON-RPC error. A2A uses the same numeric codes for its own errors (-32001 is TaskNotFound in A2A), so the gateway marks its errors with domain: maqpna.com:
{"jsonrpc":"2.0","id":3,"error":{"code":-32001,"message":"denied by policy a2a","data":{"domain":"maqpna.com","policy":"a2a","reason":"caller_not_allowed","rule":"allowed-callers"}}}
The HTTP+JSON binding returns a google.rpc.Status with an ErrorInfo detail whose reason is the upper-case token (CALLER_NOT_ALLOWED) and whose domain is maqpna.com; 401 UNAUTHENTICATED, 403 PERMISSION_DENIED, 404 for an unknown agent, 409 ABORTED while an approval is pending. maqpna a2a routes lists the effective routes and peer card verification state; see maqpna a2a routes. Metrics: maqpna_gateway_a2a_calls_total{hop,kind,decision}, maqpna_gateway_a2a_routes.
Failure modes#
| Failure | Effect |
|---|---|
| Peer card cannot be fetched or verified | Calls to that peer fail closed (peer_card_unverified); errors are cached 10 seconds |
| Callee endpoint down | upstream_unavailable, audited |
| Broker refuses delegation | B gets an RFC 6749 error and holds no delegated token |
Inbound calls from remote peers into hosted agents, and cross-organisation clearing, are Planned.