MAQPNADocs

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"]
  1. Authenticate the caller's token, and bind its workload certificate when tls.svidMode is set.
  2. Kill switch for the caller and for every agent in its act chain, so revoking any agent upstream of a delegation stops it here.
  3. Callee revoked? A revoked target answers callee_revoked.
  4. Scope. The token must hold agents:<name> (same namespace) or agents:<ns>/<name> (across namespaces). tools:* never grants it.
  5. Allowed callers. The target's allowedCallers (<agent> or <ns>/<agent> globs); empty means the target's own namespace only.
  6. Depth. A delegation chain longer than a2a.maxDelegationDepth (default 5) is refused.
  7. Loop. A call back into a hosted agent already in the chain is refused.
  8. Skill. The skill comes from message.metadata.skillId or params.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 its skills allow-list.
  9. 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. requireSignedCard defaults to true.
  10. The tool-call path. DLP on params, then policy with server a2a/<agent> (a2a/<ns>/<agent> across namespaces) and tool SendMessage (also for SendStreamingMessage), skill:<id> or the method name; tool pins; session and hierarchical budgets; approvals (X-Maqpna-Async, X-Maqpna-Approval-Id, four-eyes).
  11. 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 (server a2a/<agent>, ext.a2aHop=outbound) and, for hosted agents, the callee's (server a2a/inbound/<callerNs>/<callerAgent>, ext.a2aHop=inbound, ext.a2aCaller = the caller's SPIFFE ID). ext.a2aCall correlates them. If the target is untrusted (all peers by default), the caller's session is tainted untrusted-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
  1. 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), and tctx.target is B.
  2. The actor token must be a session token, not an exchanged or delegated one.
  3. 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).
  4. The new token nests the chain (outermost = immediate delegator), keeps the user's groups and verification, and carries maqpna_delegator_jti.
  5. At the gateway, Claims.User() walks the chain to the user: entry, so policy principals keep matching the human, and every record carries ext.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.