agentplane/peers/mod.rs
1//! Calling other agents.
2//!
3//! A peer hop is a tool call with two extra problems, and both are about
4//! identity rather than transport.
5//!
6//! # Token confusion
7//!
8//! To call a peer you hand it a credential. If that credential is not bound to
9//! *that peer*, the peer can replay it somewhere else — and it does not need to
10//! be malicious to do so, only compromised or confused. A bearer token sent to
11//! peer B and accepted by peer A is the whole vulnerability class, and it is why
12//! OAuth grew Resource Indicators (RFC 8707): the token says which audience it
13//! is for, and everyone else refuses it.
14//!
15//! This runtime cannot make peer A check that. What it *can* do — and what
16//! [`PeerRegistry`] enforces — is never send a credential to an audience it was
17//! not minted for. A credential for `settlement.example` is structurally
18//! unusable when calling `reviewer.example`; the call is refused before anything
19//! leaves.
20//!
21//! # Authority must narrow at the boundary
22//!
23//! A peer acts on our behalf, so the chain it receives is our chain with one
24//! more link. [`Delegation::delegate`] already refuses to widen and caps depth,
25//! so a hop cannot hand a peer more authority than the caller holds, and a
26//! request cannot wander arbitrarily far from the human who authorised it.
27//! "Our chain" is the **run's** — `StepCtx::acting_as`, which on a served
28//! plane is the caller's — never one a skill holds for itself, because a
29//! chain held by the skill is the same owner on every call whoever asked.
30//!
31//! # Who asked
32//!
33//! The chain narrows in-process; what reaches the peer is a credential. A
34//! credential held for the peer names nobody, so every run on the plane looks
35//! the same from the far side. A peer wired with a
36//! [`CredentialSource`] is called instead with a credential the source obtains
37//! for the run's owner — the person the run acts for — with the plane as actor,
38//! and a run that acts for nobody is refused rather than sent out under the
39//! plane's name. Which of the two a hop presented is on its announcement
40//! ([`CredentialBinding`]); the credential never is.
41//!
42//! The registry decides what each peer is *granted*, and that is an operator's
43//! declaration. It is not taken from the peer's agent card, for exactly the
44//! reason MCP annotations are not taken from a server: a party describing its own
45//! privileges is not a source of truth about them.
46
47use std::collections::BTreeMap;
48use std::fmt::Debug;
49use std::sync::Arc;
50
51use async_trait::async_trait;
52use serde::{Deserialize, Serialize};
53use serde_json::Value;
54
55/// The A2A protocol version this crate speaks, as client and as server.
56///
57/// One definition because it is one fact. The client sends it in `A2A-Version`,
58/// the published card names it on every interface, and the server refuses a
59/// request that asks for something else — three places that must agree, and a
60/// version that disagrees with the card is the kind of drift a caller finds
61/// before we do.
62pub const PROTOCOL_VERSION: &str = "1.0";
63
64/// The `google.rpc.ErrorInfo` domain under which this plane defines its own
65/// error reasons on the A2A surface.
66///
67/// One definition read by both halves, because the pair `(domain, reason)` is
68/// what makes a server-defined error *identifiable*: the numeric code alone
69/// sits in the range JSON-RPC gives implementations and A2A 1.0 reserves for
70/// its own table, so two parties can hold the same number for different
71/// facts. A domain this project controls cannot collide with either.
72pub const ERROR_DOMAIN: &str = "agentplane.hupe1980.github.io";
73
74/// The reason token a full quota answers with, inside [`ERROR_DOMAIN`].
75///
76/// The A2A client refuses-and-backs-off only on this exact pair, so the token
77/// is protocol surface, not a message string.
78pub const QUOTA_EXHAUSTED_REASON: &str = "QUOTA_EXHAUSTED";
79
80/// The reason token a halted agent answers with, inside [`ERROR_DOMAIN`].
81///
82/// Distinct from [`QUOTA_EXHAUSTED_REASON`] because the two ask opposite
83/// things of a caller: a ceiling says *come back*, and a halt says *somebody
84/// is dealing with an incident* — retrying is exactly what an operator pulling
85/// the switch is trying to stop. Answered under the quota token, a halt would
86/// teach every peer to hammer the one refusal that means stop.
87pub const HALTED_REASON: &str = "HALTED";
88
89/// The reason token a draining instance answers with, inside [`ERROR_DOMAIN`].
90///
91/// The third of the three admission refusals, and the only one a caller can act
92/// on *immediately*: a ceiling clears when a run finishes on that plane, a halt
93/// clears when a person lifts it, and this one clears the moment the caller
94/// reaches a different instance. Answered under the quota token it would teach a
95/// peer to wait out a back-off for a refusal that a retry now would pass.
96pub const DRAINING_REASON: &str = "DRAINING";
97
98/// Parse an A2A protocol version into the `Major.Minor` pair used for
99/// negotiation.
100///
101/// The specification requires decimal `Major.Minor`. A numeric patch is
102/// tolerated because patch releases MUST NOT affect compatibility, but an
103/// arbitrary suffix is not a patch version and must not turn `1.0.preview`
104/// into `1.0`. Keeping this in one place prevents card selection and server
105/// negotiation from accepting different version languages.
106#[cfg(any(feature = "a2a-server", all(feature = "a2a", feature = "manifest")))]
107pub(crate) fn protocol_major_minor(version: &str) -> Option<(u64, u64)> {
108 let mut parts = version.split('.');
109 let major = parts.next()?.parse().ok()?;
110 let minor = parts.next()?.parse().ok()?;
111 match parts.next() {
112 None => Some((major, minor)),
113 Some(patch) if !patch.is_empty() && patch.parse::<u64>().is_ok() => {
114 if parts.next().is_none() {
115 Some((major, minor))
116 } else {
117 None
118 }
119 }
120 Some(_) => None,
121 }
122}
123
124#[cfg(feature = "a2a")]
125pub mod a2a;
126#[cfg(feature = "manifest")]
127mod card;
128#[cfg(feature = "manifest")]
129mod card_sig;
130#[cfg(all(feature = "manifest", feature = "a2a"))]
131mod discovery;
132#[cfg(feature = "manifest")]
133pub use card::{
134 AgentCard, AgentExtension, CardCapabilities, CardInterface, CardSecurity,
135 CardSecurityRequirement, CardSecurityScheme, CardSkill, EXT_AGENT_DIRECTORY, EXT_GOVERNANCE,
136 EXT_MANIFEST_PROVENANCE, ExtendedAgentCard, ExtendedBudget, ExtendedTool,
137 HttpAuthSecurityScheme, SecurityScopeList, WELL_KNOWN_PATH, agent_card_path,
138};
139#[cfg(feature = "manifest")]
140pub use card_sig::{
141 ALG, CardSignature, CardSignatureError, CardSigner, CardVerifier, signing_input,
142};
143#[cfg(all(feature = "manifest", feature = "a2a"))]
144pub use discovery::{CardClient, DiscoveryError, JSONRPC};
145mod credentials;
146pub use credentials::{Cached, CredentialError, CredentialSource, TokenExchange};
147#[cfg(feature = "a2a")]
148mod exchange;
149#[cfg(feature = "a2a")]
150pub use exchange::{ACCESS_TOKEN_TYPE, TOKEN_EXCHANGE_GRANT, TokenEndpoint};
151
152use std::time::Duration;
153
154use crate::core::{
155 Capability, CredentialBinding, Delegation, DelegationError, Disposition, Effect,
156 EffectDescriptor, EffectError, Principal, ProtectedField, Recovery, RetryPolicy, Scope, Secret,
157 Sensitivity, SourceId, Timestamp, Trust,
158};
159
160/// Another agent, addressed by the name this plane knows it by.
161///
162/// Local, like a tool's server name. A peer that could choose its own identifier
163/// could step into another peer's grant.
164#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
165pub struct PeerId(pub String);
166
167impl PeerId {
168 pub fn new(s: impl Into<String>) -> Self {
169 Self(s.into())
170 }
171}
172
173impl std::fmt::Display for PeerId {
174 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 f.write_str(&self.0)
176 }
177}
178
179/// A credential, and the single audience it may be presented to.
180///
181/// The audience is not decoration. A bearer token with no stated audience is one
182/// that any recipient can replay at any other, and the whole point of RFC 8707 is
183/// that a token names where it is allowed to be spent.
184#[derive(Clone, PartialEq, Eq)]
185pub struct PeerCredential {
186 audience: PeerId,
187 /// The principal it was issued for, when the issuer named one.
188 subject: Option<String>,
189 /// Wiped when it drops, and compared in constant time.
190 ///
191 /// The redacting `Debug` and the absent `Serialize` stop it being *written*
192 /// somewhere. Neither stops it *staying* in freed heap after the credential
193 /// is gone, where a core dump or a swap file finds it — which is what
194 /// [`Secret`] is for.
195 secret: Secret,
196 /// When it stops being accepted, if the issuer said.
197 ///
198 /// `None` means the issuer gave no expiry — treated as usable, because
199 /// inventing one here would either reject working credentials or invent a
200 /// guarantee the issuer did not make.
201 expires_at: Option<Timestamp>,
202}
203
204impl PeerCredential {
205 /// Mint a credential for exactly one peer, naming nobody.
206 pub fn for_audience(audience: PeerId, secret: impl Into<String>) -> Self {
207 Self {
208 audience,
209 subject: None,
210 secret: Secret::new(secret),
211 expires_at: None,
212 }
213 }
214
215 /// Mint a credential for exactly one peer, naming the principal it was
216 /// issued for.
217 pub fn for_subject(
218 audience: PeerId,
219 subject: impl Into<String>,
220 secret: impl Into<String>,
221 ) -> Self {
222 Self {
223 subject: Some(subject.into()),
224 ..Self::for_audience(audience, secret)
225 }
226 }
227
228 /// Say when this credential stops being accepted.
229 #[must_use]
230 pub const fn expiring_at(mut self, at: Timestamp) -> Self {
231 self.expires_at = Some(at);
232 self
233 }
234
235 #[must_use]
236 pub const fn expires_at(&self) -> Option<Timestamp> {
237 self.expires_at
238 }
239
240 /// Whether this is still worth sending at `now`.
241 ///
242 /// `skew` is subtracted from the expiry, so a credential that expires in two
243 /// seconds is treated as already spent. Without that margin a token is sent
244 /// *just* before it lapses and is rejected in flight — which arrives as a
245 /// peer failure of unknown disposition, when it was really a refresh nobody
246 /// scheduled.
247 ///
248 /// `now` is a parameter rather than a clock read: expiry is transport
249 /// metadata and this keeps it testable at arbitrary instants.
250 #[must_use]
251 pub fn is_usable_at(&self, now: Timestamp, skew: Duration) -> bool {
252 let Some(expiry) = self.expires_at else {
253 return true;
254 };
255 let margin = i64::try_from(skew.as_secs()).unwrap_or(i64::MAX);
256 now.unix_timestamp().saturating_add(margin) < expiry.unix_timestamp()
257 }
258
259 #[must_use]
260 pub const fn audience(&self) -> &PeerId {
261 &self.audience
262 }
263
264 /// The principal this credential was issued for, if it names one.
265 #[must_use]
266 pub fn subject(&self) -> Option<&str> {
267 self.subject.as_deref()
268 }
269
270 /// The bearer value.
271 ///
272 /// Reachable only once a caller has already been past the audience check, so
273 /// there is no path that sends this to the wrong peer without going around
274 /// [`PeerRegistry::credential_for`] deliberately.
275 #[must_use]
276 pub fn expose(&self) -> &str {
277 self.secret.expose()
278 }
279}
280
281/// Never renders the secret.
282///
283/// A credential that prints itself ends up in a log, a span attribute, or an
284/// error message — and this crate writes all three. The audience is shown
285/// because that is the part worth debugging.
286impl Debug for PeerCredential {
287 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288 f.debug_struct("PeerCredential")
289 .field("audience", &self.audience)
290 .field("subject", &self.subject)
291 .field("expires_at", &self.expires_at)
292 .field("secret", &"<redacted>")
293 .finish()
294 }
295}
296
297/// What the operator grants a peer.
298#[derive(Debug, Clone)]
299pub struct PeerGrant {
300 /// The authority this peer may hold. Narrowed further by the caller's own
301 /// scope at every hop — a grant is a ceiling, not an entitlement.
302 pub scope: Scope,
303 /// Whether a call to this peer changes the world.
304 pub mutates: bool,
305 pub recovery: Recovery,
306 /// The most sensitive data a call may send. A call is held to the lower
307 /// of this and its own tool grant's ceiling, so the wiring bounds every
308 /// declaration that names the peer.
309 pub max_sensitivity: Sensitivity,
310 pub output_sensitivity: Sensitivity,
311 pub retry: RetryPolicy,
312 /// The credential held for this peer, naming nobody. On a peer with a
313 /// [`source`](Self::with_source), the plane's own credential, presented
314 /// only for a run admitted as the plane.
315 credential: Option<PeerCredential>,
316 /// Where a credential naming the run's owner comes from, for a peer that
317 /// is told who asked.
318 source: Option<Arc<dyn CredentialSource>>,
319}
320
321impl PeerGrant {
322 /// The grant a set of reviewed tool grants naming one peer implies: their
323 /// tools as the scope, read-only only when every grant says `mutates:
324 /// false`, and the highest ceiling any of them declares.
325 ///
326 /// For a plane whose manifests are its wiring, so the operator's ceiling
327 /// and the declaration's cannot disagree.
328 #[cfg(feature = "manifest")]
329 #[must_use]
330 pub fn from_grants<'g>(
331 grants: impl IntoIterator<Item = (String, &'g crate::manifest::ToolGrant)>,
332 ) -> Self {
333 let grants: Vec<_> = grants.into_iter().collect();
334 let mut grant = Self::new(Scope::of(grants.iter().map(|(tool, _)| tool.clone())));
335 if grants.iter().all(|(_, g)| !g.mutates) {
336 grant = grant.read_only();
337 }
338 grant.max_sensitivity = grants
339 .iter()
340 .map(|(_, g)| crate::tools::ToolSafety::from_grant(g).max_sensitivity)
341 .max()
342 .unwrap_or(grant.max_sensitivity);
343 grant
344 }
345
346 /// A grant with the conservative posture: mutating, operator-resolved.
347 #[must_use]
348 pub fn new(scope: Scope) -> Self {
349 Self {
350 scope,
351 mutates: true,
352 recovery: Recovery::RequiresOperator,
353 max_sensitivity: Sensitivity::Public,
354 output_sensitivity: Sensitivity::Public,
355 retry: RetryPolicy::never(),
356 credential: None,
357 source: None,
358 }
359 }
360
361 /// Attach the credential this peer is called with.
362 ///
363 /// # Panics
364 ///
365 /// If the credential's audience is not this peer, or it names a subject.
366 /// Both are configuration errors the operator must see at startup rather
367 /// than a refusal at 3am: the first is a credential that can only ever be
368 /// sent to the wrong place, and the second one person's token presented
369 /// for every run while the record calls it unbound — a credential that
370 /// names its subject comes from [`with_source`](Self::with_source).
371 #[must_use]
372 pub fn with_credential(mut self, peer: &PeerId, credential: PeerCredential) -> Self {
373 assert!(
374 credential.subject().is_none(),
375 "a credential naming '{}' was attached to peer '{peer}' as the one every \
376 run presents; a credential bound to a person is obtained per run, from \
377 a credential source",
378 credential.subject().unwrap_or_default()
379 );
380 assert_eq!(
381 credential.audience(),
382 peer,
383 "a credential for '{}' was attached to peer '{peer}'. An audience-bound \
384 credential presented to the wrong peer is exactly what binding exists \
385 to prevent",
386 credential.audience()
387 );
388 self.credential = Some(credential);
389 self
390 }
391
392 /// Call this peer with a credential naming the person each run acts for,
393 /// obtained from `source` when the call is made.
394 ///
395 /// From then on a run that acts for nobody is refused this peer rather
396 /// than sent out under the plane's name, and the credential held through
397 /// [`with_credential`](Self::with_credential), if any, is presented only
398 /// for a run admitted as the plane.
399 #[must_use]
400 pub fn with_source(mut self, source: Arc<dyn CredentialSource>) -> Self {
401 self.source = Some(source);
402 self
403 }
404
405 /// Where this peer's subject-bound credentials come from, if it has one.
406 #[must_use]
407 pub fn source(&self) -> Option<&Arc<dyn CredentialSource>> {
408 self.source.as_ref()
409 }
410
411 #[must_use]
412 pub fn read_only(mut self) -> Self {
413 self.mutates = false;
414 self.recovery = Recovery::Retry;
415 self
416 }
417
418 #[must_use]
419 pub const fn output_sensitivity(mut self, s: Sensitivity) -> Self {
420 self.output_sensitivity = s;
421 self
422 }
423}
424
425/// Why a peer call could not be made, or did not work.
426#[derive(Debug, thiserror::Error)]
427pub enum PeerError {
428 /// This peer is not in the registry.
429 #[error(
430 "peer '{peer}' is not registered; a peer nobody declared is a peer nobody granted anything"
431 )]
432 Unknown { peer: PeerId },
433
434 /// The hop would hand the peer authority the caller does not hold, or would
435 /// take the request too far from the human who authorised it.
436 #[error("delegating to '{peer}' is refused: {source}")]
437 Delegation {
438 peer: PeerId,
439 /// Boxed: the refusal names both links' bounds, and every peer
440 /// call's `Result` would otherwise carry that width on its happy path.
441 #[source]
442 source: Box<DelegationError>,
443 },
444
445 /// The credential on hand is for someone else.
446 #[error(
447 "the credential held for this call is bound to '{held_for}', not '{peer}' — \
448 presenting it would let '{peer}' replay it at '{held_for}'"
449 )]
450 WrongAudience { peer: PeerId, held_for: PeerId },
451
452 /// The peer is told who each call is for, and this run acts for nobody.
453 ///
454 /// Refused rather than sent with the credential held for the peer: that
455 /// credential names nobody, and presenting it for a run with no chain is
456 /// the ambient authority a subject-bound peer was wired to rule out.
457 #[error(
458 "peer '{peer}' is called with a credential naming who asked, and this run acts \
459 for nobody — admit it under a chain"
460 )]
461 NoSubject { peer: PeerId },
462
463 /// A run admitted as the plane called a subject-bound peer, and the grant
464 /// holds no credential of the plane's own for it.
465 #[error(
466 "peer '{peer}' is called with a credential naming who asked; a run admitted as \
467 the plane presents the plane's own, and none is held for '{peer}'"
468 )]
469 NoPlaneCredential { peer: PeerId },
470
471 /// The capability asked for is outside what this peer is granted.
472 ///
473 /// The peer would refuse it at admission — the chain it receives permits
474 /// only the grant's scope — but a call that cannot succeed should not
475 /// leave: it costs a round trip, lands in the peer's journal as a refused
476 /// admission, and reads to the operator as the peer declining rather than
477 /// as this plane never having granted it.
478 #[error(
479 "peer '{peer}' is not granted '{capability}'; the registry's scope for it is the \
480 ceiling on what this plane may ask it to do"
481 )]
482 NotGranted { peer: PeerId, capability: String },
483
484 /// The call never left.
485 #[error("could not reach '{peer}': {detail}")]
486 Unreachable { peer: PeerId, detail: String },
487
488 /// The peer received it and declined without acting.
489 #[error("'{peer}' refused the request: {detail}")]
490 Refused { peer: PeerId, detail: String },
491
492 /// Sent, and no answer came within the deadline.
493 ///
494 /// Only for actual timeouts. Everything else that leaves the outcome
495 /// unknown — an answered fault, an unreadable response, a connection that
496 /// died mid-flight — is [`InDoubt`](Self::InDoubt), because "did not
497 /// answer in time" is a false diagnosis of a peer that answered HTTP 500
498 /// promptly, and a false diagnosis is what an operator debugs first.
499 #[error("'{peer}' did not answer in time: {detail}")]
500 TimedOut { peer: PeerId, detail: String },
501
502 /// The request may have reached the peer, and the outcome is unknown.
503 ///
504 /// The in-doubt bucket stated honestly: an HTTP 5xx, a JSON-RPC internal
505 /// error, a response that could not be read, a request that failed
506 /// mid-flight. Each says the peer may have acted; none says it was slow.
507 #[error("the outcome at '{peer}' is unknown: {detail}")]
508 InDoubt { peer: PeerId, detail: String },
509
510 /// Sent, but the peer's response did not conform to the negotiated protocol.
511 ///
512 /// The request may already have caused work, so this is in doubt rather
513 /// than a clean refusal.
514 #[error("'{peer}' returned an invalid response: {detail}")]
515 InvalidResponse { peer: PeerId, detail: String },
516
517 /// The peer acted and reported failure.
518 #[error("'{peer}' reported a failure: {detail}")]
519 Failed { peer: PeerId, detail: String },
520}
521
522impl PeerError {
523 /// What this failure says about whether the request reached the peer.
524 #[must_use]
525 pub const fn disposition(&self) -> Disposition {
526 match self {
527 // Nothing was sent: refused locally, or refused by the peer before
528 // it acted.
529 Self::Unknown { .. }
530 | Self::Delegation { .. }
531 | Self::WrongAudience { .. }
532 | Self::NoSubject { .. }
533 | Self::NoPlaneCredential { .. }
534 | Self::NotGranted { .. }
535 | Self::Unreachable { .. }
536 | Self::Refused { .. } => Disposition::DidNotHappen,
537 Self::TimedOut { .. } | Self::InDoubt { .. } | Self::InvalidResponse { .. } => {
538 Disposition::InDoubt
539 }
540 Self::Failed { .. } => Disposition::Landed,
541 }
542 }
543}
544
545/// Carries a request to a peer.
546#[async_trait]
547pub trait PeerClient: Send + Sync + Debug {
548 /// Send a request on behalf of a delegation chain.
549 ///
550 /// # Errors
551 ///
552 /// A [`PeerError`] whose variant states what is known about whether the
553 /// request reached the peer.
554 async fn send(
555 &self,
556 peer: &PeerId,
557 capability: &str,
558 payload: &Value,
559 acting_as: &Delegation,
560 credential: Option<&PeerCredential>,
561 provenance: Option<&crate::core::Provenance>,
562 ) -> Result<Value, PeerError>;
563
564 /// Read one previously accepted remote task.
565 ///
566 /// A default refusal keeps non-task peer transports honest. Implementors
567 /// must override this only when the wire has a stable task handle and an
568 /// idempotent read operation.
569 async fn get_task(
570 &self,
571 peer: &PeerId,
572 task_id: &str,
573 credential: Option<&PeerCredential>,
574 ) -> Result<Value, PeerError> {
575 let _ = (task_id, credential);
576 Err(PeerError::Refused {
577 peer: peer.clone(),
578 detail: "this peer transport does not support task lookup".to_owned(),
579 })
580 }
581
582 /// Ask the peer to stop one previously accepted remote task.
583 ///
584 /// Cooperative, exactly as this plane's own server implements it: the
585 /// answer means the request was durably recorded, and the task stops at
586 /// its next step boundary — polling is how the eventual state is
587 /// observed. The same default refusal as [`get_task`](Self::get_task),
588 /// for the same reason.
589 async fn cancel_task(
590 &self,
591 peer: &PeerId,
592 task_id: &str,
593 credential: Option<&PeerCredential>,
594 ) -> Result<Value, PeerError> {
595 let _ = (task_id, credential);
596 Err(PeerError::Refused {
597 peer: peer.clone(),
598 detail: "this peer transport does not support task cancellation".to_owned(),
599 })
600 }
601}
602
603/// One client per peer, resolved by the name a grant carries.
604///
605/// The peers' twin of `ToolRouter`, for the same reason: a single transport
606/// handed every peer id would have to hold one endpoint, and a plane that
607/// consults two peers has two. A peer nobody routed is unreachable — a
608/// refusal that never leaves — rather than a guess at the nearest endpoint.
609#[derive(Debug, Default)]
610pub struct PeerRouter {
611 routes: BTreeMap<PeerId, Arc<dyn PeerClient>>,
612}
613
614impl PeerRouter {
615 #[must_use]
616 pub fn new() -> Self {
617 Self::default()
618 }
619
620 /// Reach one peer through one client.
621 ///
622 /// # Panics
623 ///
624 /// If the peer is already routed: silently replacing would make
625 /// registration order decide which endpoint a call reaches.
626 #[must_use]
627 pub fn peer(mut self, peer: PeerId, client: Arc<dyn PeerClient>) -> Self {
628 assert!(
629 !self.routes.contains_key(&peer),
630 "peer '{peer}' is routed twice — one of the two endpoints would silently \
631 never be called"
632 );
633 self.routes.insert(peer, client);
634 self
635 }
636
637 /// The peers this router can reach.
638 pub fn peers(&self) -> impl Iterator<Item = &PeerId> {
639 self.routes.keys()
640 }
641
642 fn route(&self, peer: &PeerId) -> Result<&Arc<dyn PeerClient>, PeerError> {
643 self.routes.get(peer).ok_or_else(|| PeerError::Unreachable {
644 peer: peer.clone(),
645 detail: format!(
646 "no transport is wired for this peer; this plane routes {:?}",
647 self.routes
648 .keys()
649 .map(ToString::to_string)
650 .collect::<Vec<_>>()
651 ),
652 })
653 }
654}
655
656#[async_trait]
657impl PeerClient for PeerRouter {
658 async fn send(
659 &self,
660 peer: &PeerId,
661 capability: &str,
662 payload: &Value,
663 acting_as: &Delegation,
664 credential: Option<&PeerCredential>,
665 provenance: Option<&crate::core::Provenance>,
666 ) -> Result<Value, PeerError> {
667 self.route(peer)?
668 .send(peer, capability, payload, acting_as, credential, provenance)
669 .await
670 }
671
672 async fn get_task(
673 &self,
674 peer: &PeerId,
675 task_id: &str,
676 credential: Option<&PeerCredential>,
677 ) -> Result<Value, PeerError> {
678 self.route(peer)?.get_task(peer, task_id, credential).await
679 }
680
681 async fn cancel_task(
682 &self,
683 peer: &PeerId,
684 task_id: &str,
685 credential: Option<&PeerCredential>,
686 ) -> Result<Value, PeerError> {
687 self.route(peer)?
688 .cancel_task(peer, task_id, credential)
689 .await
690 }
691}
692
693/// A stable handle returned by a remote peer.
694#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
695pub struct PeerTask {
696 pub peer: PeerId,
697 pub id: String,
698 #[serde(default, skip_serializing_if = "Option::is_none")]
699 pub context_id: Option<String>,
700}
701
702impl PeerTask {
703 /// Extract a task handle from a peer response, or `None` for a direct
704 /// message response.
705 pub fn from_response(peer: PeerId, response: &Value) -> Result<Option<Self>, PeerError> {
706 if response.get("role").is_some() {
707 return Ok(None);
708 }
709 let id = response.get("id").and_then(Value::as_str).ok_or_else(|| {
710 PeerError::InvalidResponse {
711 peer: peer.clone(),
712 detail: "task response has no string id".to_owned(),
713 }
714 })?;
715 if response
716 .get("status")
717 .and_then(|status| status.get("state"))
718 .and_then(Value::as_str)
719 .is_none()
720 {
721 return Err(PeerError::InvalidResponse {
722 peer,
723 detail: "task response has no status.state".to_owned(),
724 });
725 }
726 Ok(Some(Self {
727 peer,
728 id: id.to_owned(),
729 context_id: response
730 .get("contextId")
731 .and_then(Value::as_str)
732 .map(ToOwned::to_owned),
733 }))
734 }
735}
736
737/// A2A's task lifecycle, normalized for callers that need to decide whether to
738/// poll, provide input, or consume the result.
739#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
740#[serde(rename_all = "snake_case")]
741#[non_exhaustive]
742pub enum PeerTaskState {
743 Submitted,
744 Working,
745 Completed,
746 Failed,
747 Canceled,
748 Rejected,
749 InputRequired,
750 AuthRequired,
751}
752
753impl PeerTaskState {
754 fn parse(peer: &PeerId, value: &Value) -> Result<Self, PeerError> {
755 match value
756 .get("status")
757 .and_then(|status| status.get("state"))
758 .and_then(Value::as_str)
759 {
760 Some("TASK_STATE_SUBMITTED") => Ok(Self::Submitted),
761 Some("TASK_STATE_WORKING") => Ok(Self::Working),
762 Some("TASK_STATE_COMPLETED") => Ok(Self::Completed),
763 Some("TASK_STATE_FAILED") => Ok(Self::Failed),
764 Some("TASK_STATE_CANCELED") => Ok(Self::Canceled),
765 Some("TASK_STATE_REJECTED") => Ok(Self::Rejected),
766 Some("TASK_STATE_INPUT_REQUIRED") => Ok(Self::InputRequired),
767 Some("TASK_STATE_AUTH_REQUIRED") => Ok(Self::AuthRequired),
768 Some(other) => Err(PeerError::InvalidResponse {
769 peer: peer.clone(),
770 detail: format!("task response has unknown state '{other}'"),
771 }),
772 None => Err(PeerError::InvalidResponse {
773 peer: peer.clone(),
774 detail: "task response has no status.state".to_owned(),
775 }),
776 }
777 }
778
779 #[must_use]
780 pub const fn is_terminal(self) -> bool {
781 matches!(
782 self,
783 Self::Completed | Self::Failed | Self::Canceled | Self::Rejected
784 )
785 }
786}
787
788/// One journaled observation of a remote task.
789#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
790pub struct PeerTaskSnapshot {
791 pub task: PeerTask,
792 pub state: PeerTaskState,
793 pub value: Value,
794}
795
796/// On whose behalf a hop presents its credential.
797#[derive(Debug, Clone, Copy)]
798pub enum Asker<'a> {
799 /// The person this chain acts for — its owner.
800 Owner(&'a Delegation),
801 /// The plane itself: a run admitted under the plane's own chain.
802 Plane,
803 /// Nobody: a run that acts under no chain.
804 Nobody,
805}
806
807/// Which credential a hop presents: decided when the call is prepared,
808/// obtained when it is performed.
809///
810/// Obtained at `perform` because that is the one place that runs live and
811/// only live: a call is constructed on every pass, a replay included, and a
812/// replay must reach no token endpoint.
813#[derive(Debug, Clone)]
814enum Presentation {
815 /// The credential held for the peer, or none; it names nobody.
816 Held(Option<PeerCredential>),
817 /// The plane's own held credential, for a run admitted as the plane.
818 Plane(PeerCredential),
819 /// Obtained from the peer's source for the run's owner.
820 Subject {
821 source: Arc<dyn CredentialSource>,
822 subject: String,
823 },
824}
825
826impl Presentation {
827 /// What the announcement records about this hop's credential.
828 fn binding(&self, audience: &PeerId) -> CredentialBinding {
829 let audience = audience.to_string();
830 match self {
831 Self::Held(_) => CredentialBinding::Unbound { audience },
832 Self::Plane(_) => CredentialBinding::Plane { audience },
833 Self::Subject { subject, .. } => CredentialBinding::Subject {
834 audience,
835 subject: subject.clone(),
836 },
837 }
838 }
839
840 /// The credential to put on the wire, checked for its audience and
841 /// subject whichever source produced it.
842 ///
843 /// A failure here is a refusal before anything was sent: an issuer that
844 /// could not be reached is [`EffectError::Unavailable`], a credential
845 /// for somebody else is [`EffectError::Refused`].
846 async fn obtain(&self, audience: &PeerId) -> Result<Option<PeerCredential>, EffectError> {
847 match self {
848 Self::Held(held) => Ok(held.clone()),
849 Self::Plane(own) => Ok(Some(own.clone())),
850 Self::Subject { source, subject } => {
851 let obtained = source
852 .credential(audience, subject, credentials::now())
853 .await
854 .and_then(|c| credentials::bound_to(c, audience, subject));
855 match obtained {
856 Ok(c) => Ok(Some(c)),
857 Err(CredentialError::Unavailable { detail, .. }) => {
858 Err(EffectError::Unavailable {
859 driver: audience.to_string(),
860 detail,
861 })
862 }
863 Err(refused) => Err(EffectError::refused(refused.to_string())),
864 }
865 }
866 }
867 }
868}
869
870/// Map a peer's failure onto what it says about the world.
871fn effect_error(peer: &PeerId, error: &PeerError) -> EffectError {
872 let detail = error.to_string();
873 match error.disposition() {
874 Disposition::DidNotHappen => EffectError::Rejected(detail),
875 Disposition::InDoubt => EffectError::Interrupted {
876 driver: peer.to_string(),
877 detail,
878 },
879 Disposition::Landed => EffectError::Performed(detail),
880 }
881}
882
883/// A journaled, idempotent read of a remote task.
884#[derive(Debug)]
885pub struct PeerTaskCall {
886 task: PeerTask,
887 grant: PeerGrant,
888 presentation: Presentation,
889 client: Arc<dyn PeerClient>,
890}
891
892impl PeerTaskCall {
893 /// Prepare a task read under the same peer grant as the call that
894 /// created it, presenting the credential `asker` is owed.
895 ///
896 /// # Errors
897 ///
898 /// [`PeerError::Unknown`] for an unregistered peer,
899 /// [`PeerError::WrongAudience`] for a held credential bound elsewhere,
900 /// and, at a peer with a credential source, [`PeerError::NoSubject`] for
901 /// a run acting for nobody and [`PeerError::NoPlaneCredential`] for a run
902 /// admitted as the plane with no held credential.
903 pub fn prepare(
904 registry: &PeerRegistry,
905 client: Arc<dyn PeerClient>,
906 task: PeerTask,
907 asker: Asker<'_>,
908 ) -> Result<Self, PeerError> {
909 let Some(grant) = registry.grant(&task.peer).cloned() else {
910 return Err(PeerError::Unknown { peer: task.peer });
911 };
912 let presentation = registry.presentation(&task.peer, asker)?;
913 Ok(Self {
914 task,
915 grant,
916 presentation,
917 client,
918 })
919 }
920}
921
922#[async_trait]
923impl Effect for PeerTaskCall {
924 type Output = PeerTaskSnapshot;
925
926 fn descriptor(&self) -> EffectDescriptor {
927 EffectDescriptor::new(
928 "a2a.task/get",
929 serde_json::json!({
930 "peer": self.task.peer.0,
931 "task_id": self.task.id,
932 "context_id": self.task.context_id,
933 }),
934 )
935 }
936
937 fn mutates(&self) -> bool {
938 false
939 }
940
941 fn recovery(&self) -> Recovery {
942 Recovery::Retry
943 }
944
945 fn retry(&self) -> RetryPolicy {
946 self.grant.retry
947 }
948
949 fn output_sensitivity(&self) -> Sensitivity {
950 self.grant.output_sensitivity
951 }
952
953 fn trust(&self) -> Trust {
954 Trust::Untrusted
955 }
956
957 fn credential_binding(&self) -> Option<CredentialBinding> {
958 Some(self.presentation.binding(&self.task.peer))
959 }
960
961 async fn perform(&self) -> Result<Self::Output, EffectError> {
962 let credential = self.presentation.obtain(&self.task.peer).await?;
963 let value = self
964 .client
965 .get_task(&self.task.peer, &self.task.id, credential.as_ref())
966 .await
967 .map_err(|error| effect_error(&self.task.peer, &error))?;
968 let state = PeerTaskState::parse(&self.task.peer, &value).map_err(|error| {
969 EffectError::Interrupted {
970 driver: self.task.peer.to_string(),
971 detail: error.to_string(),
972 }
973 })?;
974 Ok(PeerTaskSnapshot {
975 task: self.task.clone(),
976 state,
977 value,
978 })
979 }
980}
981
982/// A journaled, cooperative cancellation of a remote task.
983///
984/// The A2A twin of [`McpTaskCancel`](crate::tools::McpTaskCancel), and it
985/// exists for the same lifecycle reason: a run that commissioned work at a
986/// peer and is itself cancelled or unwound must be able to tell the peer to
987/// stop, or the cancellation ends at this plane's edge while the peer keeps
988/// spending on an answer nobody will read.
989///
990/// # What the answer means
991///
992/// Acceptance of intent, never completion: this plane's own server records
993/// the request durably and the run stops at its next step boundary, so the
994/// snapshot that comes back is typically still `Working`. Polling through
995/// [`PeerTaskCall`] is how the eventual `Canceled` is observed. A task that
996/// already finished is *refused* by the far side (A2A's `TaskNotCancelable`,
997/// `-32002`) — a clean, pre-action decline, which is why retrying this
998/// effect is safe: a repeat of a cancel that landed meets that refusal
999/// rather than a second effect.
1000#[derive(Debug)]
1001pub struct PeerTaskCancel {
1002 task: PeerTask,
1003 grant: PeerGrant,
1004 presentation: Presentation,
1005 client: Arc<dyn PeerClient>,
1006}
1007
1008impl PeerTaskCancel {
1009 /// Prepare a cancellation under the same peer grant as the call that
1010 /// created the task, presenting the credential `asker` is owed.
1011 ///
1012 /// # Errors
1013 ///
1014 /// As [`PeerTaskCall::prepare`].
1015 pub fn prepare(
1016 registry: &PeerRegistry,
1017 client: Arc<dyn PeerClient>,
1018 task: PeerTask,
1019 asker: Asker<'_>,
1020 ) -> Result<Self, PeerError> {
1021 let Some(grant) = registry.grant(&task.peer).cloned() else {
1022 return Err(PeerError::Unknown { peer: task.peer });
1023 };
1024 let presentation = registry.presentation(&task.peer, asker)?;
1025 Ok(Self {
1026 task,
1027 grant,
1028 presentation,
1029 client,
1030 })
1031 }
1032}
1033
1034#[async_trait]
1035impl Effect for PeerTaskCancel {
1036 type Output = PeerTaskSnapshot;
1037
1038 fn descriptor(&self) -> EffectDescriptor {
1039 EffectDescriptor::new(
1040 "a2a.task/cancel",
1041 serde_json::json!({
1042 "peer": self.task.peer.0,
1043 "task_id": self.task.id,
1044 "context_id": self.task.context_id,
1045 }),
1046 )
1047 }
1048
1049 /// Asking a peer to stop is asking it to change what happens.
1050 fn mutates(&self) -> bool {
1051 true
1052 }
1053
1054 /// Retry is safe despite mutating: the far side answers a repeat of a
1055 /// cancel that landed with a clean refusal (`TaskNotCancelable`), never
1056 /// with a second act — the type-level statement of the module docs.
1057 fn recovery(&self) -> Recovery {
1058 Recovery::Retry
1059 }
1060
1061 fn retry(&self) -> RetryPolicy {
1062 self.grant.retry
1063 }
1064
1065 fn output_sensitivity(&self) -> Sensitivity {
1066 self.grant.output_sensitivity
1067 }
1068
1069 fn trust(&self) -> Trust {
1070 Trust::Untrusted
1071 }
1072
1073 fn credential_binding(&self) -> Option<CredentialBinding> {
1074 Some(self.presentation.binding(&self.task.peer))
1075 }
1076
1077 async fn perform(&self) -> Result<Self::Output, EffectError> {
1078 let credential = self.presentation.obtain(&self.task.peer).await?;
1079 let value = self
1080 .client
1081 .cancel_task(&self.task.peer, &self.task.id, credential.as_ref())
1082 .await
1083 .map_err(|error| effect_error(&self.task.peer, &error))?;
1084 let state = PeerTaskState::parse(&self.task.peer, &value).map_err(|error| {
1085 EffectError::Interrupted {
1086 driver: self.task.peer.to_string(),
1087 detail: error.to_string(),
1088 }
1089 })?;
1090 Ok(PeerTaskSnapshot {
1091 task: self.task.clone(),
1092 state,
1093 value,
1094 })
1095 }
1096}
1097
1098/// The peers this plane may call, and what each is granted.
1099#[derive(Debug, Default, Clone)]
1100pub struct PeerRegistry {
1101 peers: BTreeMap<PeerId, PeerGrant>,
1102}
1103
1104impl PeerRegistry {
1105 #[must_use]
1106 pub fn new() -> Self {
1107 Self::default()
1108 }
1109
1110 #[must_use]
1111 pub fn allow(mut self, peer: PeerId, grant: PeerGrant) -> Self {
1112 self.peers.insert(peer, grant);
1113 self
1114 }
1115
1116 #[must_use]
1117 pub fn grant(&self, peer: &PeerId) -> Option<&PeerGrant> {
1118 self.peers.get(peer)
1119 }
1120
1121 /// Every peer this registry names.
1122 pub fn peers(&self) -> impl Iterator<Item = &PeerId> {
1123 self.peers.keys()
1124 }
1125
1126 /// The credential for this peer, if one is held *and* bound to it.
1127 ///
1128 /// The audience check is here rather than at the call site so that no code
1129 /// path can reach a credential without passing it.
1130 ///
1131 /// # Errors
1132 ///
1133 /// [`PeerError::WrongAudience`] if a credential is held whose audience is a
1134 /// different peer.
1135 pub fn credential_for(&self, peer: &PeerId) -> Result<Option<&PeerCredential>, PeerError> {
1136 let Some(grant) = self.peers.get(peer) else {
1137 return Ok(None);
1138 };
1139 match grant.credential.as_ref() {
1140 None => Ok(None),
1141 Some(c) if c.audience() == peer => Ok(Some(c)),
1142 Some(c) => Err(PeerError::WrongAudience {
1143 peer: peer.clone(),
1144 held_for: c.audience().clone(),
1145 }),
1146 }
1147 }
1148
1149 /// Which credential a hop to `peer` presents for `asker`.
1150 ///
1151 /// A peer without a source presents the credential held for it, whoever
1152 /// asked. A peer with one presents a credential naming the chain's owner,
1153 /// the plane's own held credential for a run admitted as the plane, and
1154 /// nothing at all for a run that acts for nobody.
1155 ///
1156 /// # Errors
1157 ///
1158 /// * [`PeerError::Unknown`] if the peer is not registered.
1159 /// * [`PeerError::WrongAudience`] if the credential held is for someone else.
1160 /// * [`PeerError::NoSubject`] for a run acting for nobody at a peer with a
1161 /// source.
1162 /// * [`PeerError::NoPlaneCredential`] for a run admitted as the plane at a
1163 /// peer with a source and no held credential.
1164 fn presentation(&self, peer: &PeerId, asker: Asker<'_>) -> Result<Presentation, PeerError> {
1165 let Some(grant) = self.peers.get(peer) else {
1166 return Err(PeerError::Unknown { peer: peer.clone() });
1167 };
1168 let held = self.credential_for(peer)?.cloned();
1169 let Some(source) = grant.source.as_ref() else {
1170 return Ok(Presentation::Held(held));
1171 };
1172 match asker {
1173 Asker::Owner(chain) => Ok(Presentation::Subject {
1174 source: Arc::clone(source),
1175 subject: chain.owner().id.clone(),
1176 }),
1177 Asker::Plane => held
1178 .map(Presentation::Plane)
1179 .ok_or_else(|| PeerError::NoPlaneCredential { peer: peer.clone() }),
1180 Asker::Nobody => Err(PeerError::NoSubject { peer: peer.clone() }),
1181 }
1182 }
1183
1184 /// Drop every credential held for `subject`, at every peer's source.
1185 pub fn forget(&self, subject: &str) {
1186 for grant in self.peers.values() {
1187 if let Some(source) = grant.source.as_ref() {
1188 source.forget(subject);
1189 }
1190 }
1191 }
1192}
1193
1194/// One request to one peer.
1195#[derive(Debug)]
1196pub struct PeerCall {
1197 peer: PeerId,
1198 capability: String,
1199 payload: Value,
1200 grant: PeerGrant,
1201 /// The chain the *peer* acts under: ours, narrowed, with the peer appended.
1202 acting_as: Delegation,
1203 presentation: Presentation,
1204 client: Arc<dyn PeerClient>,
1205 /// Who is calling, sealed for this hop. Set by the runtime via
1206 /// [`Effect::attach`](crate::core::Effect::attach).
1207 provenance: Option<crate::core::Provenance>,
1208 /// Authority-bearing payload fields and their source rules, from the
1209 /// manifest grant that governs this call — see
1210 /// [`governed_by`](Self::governed_by). Empty for a call nothing governs.
1211 protected: Vec<ProtectedField>,
1212}
1213
1214impl PeerCall {
1215 /// Prepare a hop, attenuating the caller's authority onto the peer and
1216 /// presenting the credential the chain's owner is owed.
1217 ///
1218 /// # Errors
1219 ///
1220 /// * [`PeerError::Unknown`] if the peer is not registered — fail closed.
1221 /// * [`PeerError::NotGranted`] if the capability is outside the grant.
1222 /// * [`PeerError::Delegation`] if the grant would widen the caller's own
1223 /// authority, or if the chain is already at its depth limit.
1224 /// * [`PeerError::WrongAudience`] if the credential held is for someone else.
1225 pub fn prepare(
1226 registry: &PeerRegistry,
1227 client: Arc<dyn PeerClient>,
1228 caller: &Delegation,
1229 peer: PeerId,
1230 capability: impl Into<String>,
1231 payload: Value,
1232 ) -> Result<Self, PeerError> {
1233 Self::build(
1234 registry,
1235 client,
1236 caller,
1237 Asker::Owner(caller),
1238 peer,
1239 capability,
1240 payload,
1241 )
1242 }
1243
1244 /// Prepare a hop for a run admitted as the plane, under the plane's own
1245 /// chain.
1246 ///
1247 /// The plane is the party that asked, so a peer told who asked is shown
1248 /// the plane's own credential rather than one naming the chain's owner as
1249 /// if a person had asked.
1250 ///
1251 /// # Errors
1252 ///
1253 /// As [`prepare`](Self::prepare), plus [`PeerError::NoPlaneCredential`].
1254 pub fn prepare_as_plane(
1255 registry: &PeerRegistry,
1256 client: Arc<dyn PeerClient>,
1257 chain: &Delegation,
1258 peer: PeerId,
1259 capability: impl Into<String>,
1260 payload: Value,
1261 ) -> Result<Self, PeerError> {
1262 Self::build(
1263 registry,
1264 client,
1265 chain,
1266 Asker::Plane,
1267 peer,
1268 capability,
1269 payload,
1270 )
1271 }
1272
1273 fn build(
1274 registry: &PeerRegistry,
1275 client: Arc<dyn PeerClient>,
1276 caller: &Delegation,
1277 asker: Asker<'_>,
1278 peer: PeerId,
1279 capability: impl Into<String>,
1280 payload: Value,
1281 ) -> Result<Self, PeerError> {
1282 let Some(grant) = registry.grant(&peer) else {
1283 return Err(PeerError::Unknown { peer });
1284 };
1285 let capability = capability.into();
1286 // Refused here rather than by the peer: the chain it would receive
1287 // permits exactly `grant.scope`, so a capability outside it is a call
1288 // the far side's admission gate refuses after a round trip, journaled
1289 // there as *their* decline.
1290 if !grant.scope.permits(&Capability::new(capability.as_str())) {
1291 return Err(PeerError::NotGranted { peer, capability });
1292 }
1293
1294 // The grant is a ceiling. What the peer actually receives is bounded by
1295 // what *we* hold, which is what `delegate` enforces — a grant wider than
1296 // the caller's own authority is refused rather than silently clipped,
1297 // because silently clipping hides a misconfiguration that matters.
1298 let acting_as = caller
1299 .delegate(Principal::new(peer.to_string(), grant.scope.clone()))
1300 .map_err(|source| PeerError::Delegation {
1301 peer: peer.clone(),
1302 source: Box::new(source),
1303 })?;
1304
1305 let presentation = registry.presentation(&peer, asker)?;
1306
1307 Ok(Self {
1308 provenance: None,
1309 grant: grant.clone(),
1310 capability,
1311 payload,
1312 acting_as,
1313 presentation,
1314 peer,
1315 client,
1316 protected: Vec::new(),
1317 })
1318 }
1319
1320 /// Hold this call to a manifest grant's declaration.
1321 ///
1322 /// A registry entry is operator *wiring* — where the peer is, what it is
1323 /// granted, which credential reaches it. The reviewed `tool://<peer>/<capability>`
1324 /// grant in the calling agent's manifest is what says how the call may be
1325 /// *used*, and it governs the same way it governs a tool: its protected
1326 /// fields are checked at the sink, its ceiling bounds what may be sent, and
1327 /// its `mutates` can only make the call more cautious than the wiring did.
1328 #[must_use]
1329 pub fn governed_by(mut self, safety: &crate::tools::ToolSafety) -> Self {
1330 self.grant.mutates |= safety.mutates;
1331 self.grant.max_sensitivity = self.grant.max_sensitivity.min(safety.max_sensitivity);
1332 self.grant.output_sensitivity =
1333 self.grant.output_sensitivity.max(safety.output_sensitivity);
1334 self.protected.clone_from(&safety.protected_fields);
1335 self
1336 }
1337
1338 /// The chain the peer will act under.
1339 #[must_use]
1340 pub const fn acting_as(&self) -> &Delegation {
1341 &self.acting_as
1342 }
1343}
1344
1345#[async_trait]
1346impl Effect for PeerCall {
1347 type Output = Value;
1348
1349 fn descriptor(&self) -> EffectDescriptor {
1350 EffectDescriptor::new(
1351 "a2a.peer/call",
1352 serde_json::json!({
1353 "peer": self.peer.0,
1354 "capability": self.capability,
1355 "payload": self.payload,
1356 }),
1357 )
1358 }
1359
1360 fn mutates(&self) -> bool {
1361 self.grant.mutates
1362 }
1363
1364 fn recovery(&self) -> Recovery {
1365 self.grant.recovery.clone()
1366 }
1367
1368 fn retry(&self) -> RetryPolicy {
1369 self.grant.retry
1370 }
1371
1372 fn max_sensitivity(&self) -> Sensitivity {
1373 self.grant.max_sensitivity
1374 }
1375
1376 fn delegation_depth(&self) -> Option<usize> {
1377 Some(self.acting_as.depth())
1378 }
1379
1380 /// The payload is what reaches the peer, so it is what the sink gate
1381 /// judges — the whole-value taint rule for a mutating grant, and the
1382 /// per-field rules a manifest declares.
1383 fn sink_arguments(&self) -> Option<&Value> {
1384 Some(&self.payload)
1385 }
1386
1387 fn protected_fields(&self) -> &[ProtectedField] {
1388 &self.protected
1389 }
1390
1391 /// The reference a manifest grants this call under, so a source rule can
1392 /// name *this* peer's answer — `tool://reviewer/audit.check` — rather than
1393 /// whichever peer an injected prompt reached first.
1394 fn source(&self) -> SourceId {
1395 SourceId::new(format!(
1396 "{}{}/{}",
1397 crate::tools::TOOL_SCHEME,
1398 self.peer,
1399 self.capability
1400 ))
1401 }
1402
1403 fn output_sensitivity(&self) -> Sensitivity {
1404 self.grant.output_sensitivity
1405 }
1406
1407 /// A peer's answer is another party's data.
1408 ///
1409 /// Stated rather than inherited because a peer feels more trusted than a
1410 /// tool — it is *our* agent, on our side. It is not: it runs somewhere else,
1411 /// under someone else's control, and it may itself have read the internet.
1412 fn trust(&self) -> Trust {
1413 Trust::Untrusted
1414 }
1415
1416 fn attach(&mut self, provenance: &crate::core::Provenance) {
1417 self.provenance = Some(provenance.clone());
1418 }
1419
1420 fn credential_binding(&self) -> Option<CredentialBinding> {
1421 Some(self.presentation.binding(&self.peer))
1422 }
1423
1424 async fn perform(&self) -> Result<Value, EffectError> {
1425 let credential = self.presentation.obtain(&self.peer).await?;
1426 self.client
1427 .send(
1428 &self.peer,
1429 &self.capability,
1430 &self.payload,
1431 &self.acting_as,
1432 credential.as_ref(),
1433 self.provenance.as_ref(),
1434 )
1435 .await
1436 .map_err(|e| effect_error(&self.peer, &e))
1437 }
1438}