Skip to main content

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}