Skip to main content

lean_ctx/core/a2a/
remote_transport.rs

1use std::time::{Duration, Instant};
2
3use chrono::{DateTime, Utc};
4use futures::StreamExt;
5use serde::{Deserialize, Serialize};
6
7use crate::core::a2a::dlq::{DeadLetter, DeadLetterDelivery, DlqScope, MAX_ERROR_BYTES};
8use crate::core::a2a::relay::{
9    RELAY_MAX_PAYLOAD_BYTES, RELAY_MAX_RETRIES, RELAY_MAX_RETRY_DELAY_MS, RELAY_PEER_HEADER,
10    RelayPeerConfigV1, RelayPeerTableV1,
11};
12use crate::core::a2a_transport::TransportEnvelopeV1;
13
14const DEFAULT_MAX_PAYLOAD_BYTES: usize = 2_000_000;
15const MAX_ERROR_BODY_BYTES: usize = 64 * 1024;
16const MAX_TASK_RESPONSE_BYTES: usize = 64 * 1024;
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19pub struct RemoteTransportConfig {
20    pub endpoint_url: String,
21    pub timeout: Duration,
22    pub max_payload_bytes: usize,
23    pub auth_token: Option<String>,
24    pub signing_key: Option<String>,
25    pub recipient_id: Option<String>,
26    pub tenant_id: Option<String>,
27    pub project_id: Option<String>,
28    pub retry_count: u8,
29    pub retry_delay: Duration,
30    #[serde(default)]
31    pub peer_id: Option<String>,
32    #[serde(default)]
33    pub local_peer_id: Option<String>,
34    #[serde(default)]
35    pub peers: Vec<RelayPeerConfigV1>,
36    #[serde(skip)]
37    pub allow_loopback_http: bool,
38}
39
40impl Default for RemoteTransportConfig {
41    fn default() -> Self {
42        Self {
43            endpoint_url: String::new(),
44            timeout: Duration::from_secs(30),
45            max_payload_bytes: DEFAULT_MAX_PAYLOAD_BYTES,
46            auth_token: None,
47            signing_key: None,
48            recipient_id: None,
49            tenant_id: None,
50            project_id: None,
51            retry_count: 2,
52            retry_delay: Duration::from_secs(1),
53            peer_id: None,
54            local_peer_id: None,
55            peers: Vec::new(),
56            allow_loopback_http: false,
57        }
58    }
59}
60
61impl RemoteTransportConfig {
62    pub fn validate(&self) -> Result<(), String> {
63        if self.timeout.is_zero() || self.max_payload_bytes == 0 {
64            return Err("transport bounds must be greater than zero".into());
65        }
66        if self.retry_count > RELAY_MAX_RETRIES
67            || self.retry_delay.as_millis() > RELAY_MAX_RETRY_DELAY_MS as u128
68        {
69            return Err("retry policy exceeds bounded relay caps".into());
70        }
71        // Presence of a local identity selects peer mode; partial or mixed
72        // authority must fail closed, never fall back to singleton credentials.
73        if self.local_peer_id.is_some() || !self.peers.is_empty() {
74            return self.validate_peer_mode();
75        }
76        if self.peer_id.is_some() {
77            return Err("singleton transport must not contain a peer_id".into());
78        }
79        let url = reqwest::Url::parse(&self.endpoint_url)
80            .map_err(|error| format!("invalid endpoint_url: {error}"))?;
81        if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
82            return Err("endpoint_url must be an absolute HTTP(S) URL".to_string());
83        }
84        if !url.username().is_empty() || url.password().is_some() {
85            return Err("endpoint_url must not contain credentials".to_string());
86        }
87        if url.scheme() == "http" && !is_loopback_host(&url) {
88            return Err("endpoint_url must use HTTPS except for loopback hosts".to_string());
89        }
90        if self.auth_token.as_ref().is_none_or(String::is_empty) {
91            return Err("auth_token is required for remote A2A delivery".to_string());
92        }
93        if self.signing_key.as_ref().is_none_or(String::is_empty) {
94            return Err("signing_key is required for remote A2A delivery".to_string());
95        }
96        if self.auth_token == self.signing_key {
97            return Err("signing_key must be distinct from auth_token".to_string());
98        }
99        if self.recipient_id.as_ref().is_none_or(String::is_empty) {
100            return Err("recipient_id is required for remote A2A delivery".to_string());
101        }
102        if self.tenant_id.as_ref().is_none_or(String::is_empty) {
103            return Err("tenant_id is required for remote A2A delivery".to_string());
104        }
105        if self.project_id.as_ref().is_none_or(String::is_empty) {
106            return Err("project_id is required for remote A2A delivery".to_string());
107        }
108        Ok(())
109    }
110
111    fn validate_peer_mode(&self) -> Result<(), String> {
112        if self.local_peer_id.as_deref().is_none_or(|id| {
113            id.is_empty() || id.len() > crate::core::a2a::relay::RELAY_MAX_ID_BYTES
114        }) || self.peers.is_empty()
115            || self.max_payload_bytes > RELAY_MAX_PAYLOAD_BYTES
116        {
117            return Err(
118                "peer transport requires a bounded local identity, peer table and payload limit"
119                    .into(),
120            );
121        }
122        if !self.endpoint_url.is_empty()
123            || [
124                &self.auth_token,
125                &self.signing_key,
126                &self.recipient_id,
127                &self.tenant_id,
128                &self.project_id,
129                &self.peer_id,
130            ]
131            .iter()
132            .any(|value| value.is_some())
133            || self.retry_count != 0
134            || !self.retry_delay.is_zero()
135        {
136            return Err(
137                "peer transport must not contain singleton authority or retry policy".into(),
138            );
139        }
140        let table = RelayPeerTableV1 {
141            schema_version: crate::core::a2a::relay::RELAY_PEER_TABLE_VERSION,
142            peers: self.peers.clone(),
143        };
144        table
145            .validate(self.allow_loopback_http)
146            .map_err(|error| error.to_string())?;
147        table
148            .reject_shared_secret()
149            .map_err(|error| error.to_string())
150    }
151
152    fn delivery_url(&self) -> Result<reqwest::Url, String> {
153        self.validate()?;
154        let mut url = reqwest::Url::parse(&self.endpoint_url)
155            .map_err(|error| format!("invalid endpoint_url: {error}"))?;
156        let path = format!("{}/a2a/deliver", url.path().trim_end_matches('/'));
157        url.set_path(&path);
158        url.set_query(None);
159        url.set_fragment(None);
160        Ok(url)
161    }
162}
163
164fn is_loopback_host(url: &reqwest::Url) -> bool {
165    url.host_str().is_some_and(|host| {
166        let ip_host = host
167            .strip_prefix('[')
168            .and_then(|value| value.strip_suffix(']'))
169            .unwrap_or(host);
170        host.eq_ignore_ascii_case("localhost")
171            || ip_host
172                .parse::<std::net::IpAddr>()
173                .is_ok_and(|address| address.is_loopback())
174    })
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct RemoteTransport {
179    config: RemoteTransportConfig,
180    #[serde(skip, default = "fixed_peer_client")]
181    client: reqwest::Client,
182}
183
184fn fixed_peer_client() -> reqwest::Client {
185    // A configured peer cannot delegate delivery authority with an HTTP redirect.
186    reqwest::Client::builder()
187        .redirect(reqwest::redirect::Policy::none())
188        .build()
189        .expect("initialize fixed-peer HTTP client")
190}
191
192impl RemoteTransport {
193    #[cfg(any(all(test, feature = "http-server"), feature = "test-support"))]
194    pub fn with_test_tls_root(mut self, certificate: reqwest::Certificate) -> Self {
195        self.client = reqwest::Client::builder()
196            .redirect(reqwest::redirect::Policy::none())
197            .add_root_certificate(certificate)
198            .build()
199            .expect("test TLS client");
200        self
201    }
202
203    pub fn new(config: RemoteTransportConfig) -> Result<Self, String> {
204        config.validate()?;
205        Ok(Self {
206            config,
207            client: fixed_peer_client(),
208        })
209    }
210
211    pub fn for_peer_table(
212        table: RelayPeerTableV1,
213        local_peer_id: &str,
214        timeout: Duration,
215        allow_loopback_http: bool,
216    ) -> Result<Self, String> {
217        table
218            .validate(allow_loopback_http)
219            .map_err(|error| error.to_string())?;
220        Self::new(RemoteTransportConfig {
221            timeout,
222            max_payload_bytes: RELAY_MAX_PAYLOAD_BYTES,
223            retry_count: 0,
224            retry_delay: Duration::ZERO,
225            local_peer_id: Some(local_peer_id.to_string()),
226            peers: table.peers,
227            allow_loopback_http,
228            ..RemoteTransportConfig::default()
229        })
230    }
231
232    pub async fn deliver(
233        &self,
234        envelope: &TransportEnvelopeV1,
235    ) -> Result<DeliveryReceipt, TransportError> {
236        self.config
237            .validate()
238            .map_err(TransportError::SerializationError)?;
239        if self.config.local_peer_id.is_some() {
240            return Err(TransportError::SerializationError(
241                "peer transport requires explicit peer delivery".into(),
242            ));
243        }
244        self.ensure_dlq_available().await?;
245        let mut signed = envelope.clone();
246        let expected_recipient = self
247            .config
248            .recipient_id
249            .as_deref()
250            .expect("validated transport has recipient identity");
251        if signed.recipient.as_deref() != Some(expected_recipient) {
252            return Err(TransportError::SerializationError(
253                "envelope recipient does not match configured remote recipient".to_string(),
254            ));
255        }
256        signed.metadata.insert(
257            "tenant_id".to_string(),
258            self.config
259                .tenant_id
260                .clone()
261                .expect("validated transport has tenant scope"),
262        );
263        signed.metadata.insert(
264            "project_id".to_string(),
265            self.config
266                .project_id
267                .clone()
268                .expect("validated transport has project scope"),
269        );
270        let secret = self
271            .config
272            .signing_key
273            .as_deref()
274            .ok_or_else(|| TransportError::SerializationError("missing signing key".into()))?;
275        signed
276            .sign(secret.as_bytes())
277            .map_err(TransportError::SerializationError)?;
278        let body = serialize_and_validate(&signed, self.config.max_payload_bytes)?;
279        let envelope_id = envelope_id(&body);
280        let delivery_url = self
281            .config
282            .delivery_url()
283            .map_err(TransportError::SerializationError)?;
284        let started_at = Instant::now();
285
286        for attempt in 0..=self.config.retry_count {
287            let mut request = self
288                .client
289                .post(delivery_url.clone())
290                .header(reqwest::header::CONTENT_TYPE, "application/json")
291                .timeout(self.config.timeout)
292                .body(body.clone());
293            if let Some(token) = self.config.auth_token.as_deref() {
294                request = request.bearer_auth(token);
295            }
296
297            match request.send().await {
298                Ok(response) if response.status().is_success() => {
299                    let remote_status = response.status().as_u16();
300                    let unverified_task_response = match read_task_response(response, &signed).await
301                    {
302                        Ok(body) => body,
303                        // A 2xx may mean the remote effect committed even when its
304                        // response stream is unusable. Do not enqueue a false
305                        // non-delivery record; the caller must reconcile/retry by ID.
306                        Err(error) => return Err(error),
307                    };
308                    return Ok(DeliveryReceipt {
309                        envelope_id,
310                        delivered_at: Utc::now(),
311                        remote_status,
312                        round_trip_ms: elapsed_millis(started_at),
313                        unverified_task_response,
314                    });
315                }
316                Ok(response) if response.status().is_server_error() => {
317                    if attempt == self.config.retry_count {
318                        return Err(self
319                            .record_failure(
320                                &signed,
321                                &body,
322                                TransportError::Exhausted(self.config.retry_count),
323                                attempt.saturating_add(1),
324                            )
325                            .await);
326                    }
327                }
328                Ok(response) => {
329                    let status = response.status();
330                    let error_body = read_error_body(response).await;
331                    let error = TransportError::RemoteError(status.as_u16(), error_body);
332                    return Err(self
333                        .record_failure(&signed, &body, error, attempt.saturating_add(1))
334                        .await);
335                }
336                Err(error) if error.is_timeout() && self.config.retry_count == 0 => {
337                    return Err(self
338                        .record_failure(
339                            &signed,
340                            &body,
341                            TransportError::Timeout,
342                            attempt.saturating_add(1),
343                        )
344                        .await);
345                }
346                Err(_) if attempt == self.config.retry_count => {
347                    return Err(self
348                        .record_failure(
349                            &signed,
350                            &body,
351                            TransportError::Exhausted(self.config.retry_count),
352                            attempt.saturating_add(1),
353                        )
354                        .await);
355                }
356                Err(_) => {}
357            }
358
359            tokio::time::sleep(self.config.retry_delay).await;
360        }
361
362        Err(TransportError::Exhausted(self.config.retry_count))
363    }
364
365    /// Deliver through one explicitly selected peer. The peer table is the
366    /// authority for route, scope, credential, and retry bounds; singleton
367    /// transport configuration is never consulted for this path.
368    pub async fn deliver_to(
369        &self,
370        peer_id: &str,
371        envelope: &TransportEnvelopeV1,
372    ) -> Result<DeliveryReceipt, TransportError> {
373        self.config
374            .validate()
375            .map_err(TransportError::SerializationError)?;
376        if self.config.local_peer_id.is_none() {
377            return Err(TransportError::SerializationError(
378                "explicit peer delivery requires peer transport".into(),
379            ));
380        }
381        let peer = self
382            .config
383            .peers
384            .iter()
385            .find(|peer| peer.peer_id == peer_id)
386            .cloned()
387            .ok_or_else(|| {
388                TransportError::SerializationError(format!("unknown relay peer: {peer_id}"))
389            })?;
390        let mut signed = envelope.clone();
391        let record = signed
392            .relay_record()
393            .map_err(TransportError::SerializationError)?
394            .ok_or_else(|| {
395                TransportError::SerializationError(
396                    "relay record is required for peer delivery".into(),
397                )
398            })?;
399        let now = Utc::now();
400        record
401            .validate_at(
402                now,
403                &record.current_peer.clone(),
404                Some(peer.peer_id.as_str()),
405                peer.max_hops,
406            )
407            .map_err(|error| TransportError::SerializationError(error.to_string()))?;
408        record
409            .verify_hop(&peer.channel_key)
410            .map_err(|error| TransportError::SerializationError(error.to_string()))?;
411        if !peer.allows(&record, signed.payload_json.len())
412            || signed.content_type != record.content_type
413            || signed.sender.agent_id != record.current_peer
414            || self.config.local_peer_id.as_deref() != Some(record.current_peer.as_str())
415            || signed.metadata.get("tenant_id").map(String::as_str)
416                != Some(record.tenant_id.as_str())
417            || signed.metadata.get("project_id").map(String::as_str)
418                != Some(record.project_id.as_str())
419            || record
420                .verify_payload(signed.payload_json.as_bytes())
421                .is_err()
422        {
423            return Err(TransportError::SerializationError(
424                "peer policy denied relay scope".into(),
425            ));
426        }
427        if signed.recipient.as_deref() != Some(record.final_recipient.as_str()) {
428            return Err(TransportError::SerializationError(
429                "envelope recipient does not match signed final recipient".into(),
430            ));
431        }
432        signed
433            .attach_relay_record(&record)
434            .map_err(TransportError::SerializationError)?;
435        signed.signature = None;
436        signed
437            .sign(peer.channel_key.as_bytes())
438            .map_err(TransportError::SerializationError)?;
439        let body = serialize_and_validate(&signed, peer.max_payload_bytes)?;
440        let delivery_id = record.delivery_id.clone();
441        let delivery_url = peer_delivery_url(&peer).map_err(TransportError::SerializationError)?;
442        self.ensure_dlq_scope(&record.tenant_id, &record.project_id)
443            .await?;
444        let started_at = Instant::now();
445        let source_peer = self
446            .config
447            .local_peer_id
448            .as_deref()
449            .unwrap_or(record.current_peer.as_str());
450
451        for attempt in 0..=peer.retry_count {
452            let request = self
453                .client
454                .post(delivery_url.clone())
455                .header(reqwest::header::CONTENT_TYPE, "application/json")
456                .header("idempotency-key", delivery_id.as_str())
457                .header(RELAY_PEER_HEADER, source_peer)
458                .timeout(self.config.timeout)
459                .bearer_auth(peer.bearer_token.as_str())
460                .body(body.clone());
461            match request.send().await {
462                Ok(response) if response.status().is_success() => {
463                    let remote_status = response.status().as_u16();
464                    let unverified_task_response = match read_task_response(response, &signed).await
465                    {
466                        Ok(body) => body,
467                        // Delivery was acknowledged before response decoding.
468                        // Recording it as a permanent non-delivery would mislead
469                        // operators and could authorize an unsafe blind replay.
470                        Err(error) => return Err(error),
471                    };
472                    return Ok(DeliveryReceipt {
473                        envelope_id: delivery_id,
474                        delivered_at: Utc::now(),
475                        remote_status,
476                        round_trip_ms: elapsed_millis(started_at),
477                        unverified_task_response,
478                    });
479                }
480                Ok(response) if response.status().is_server_error() => {
481                    if attempt == peer.retry_count {
482                        let failure = TransportError::Exhausted(peer.retry_count);
483                        return Err(self
484                            .record_failure_for_peer(
485                                &signed,
486                                &body,
487                                &failure,
488                                attempt.saturating_add(1),
489                                &peer,
490                            )
491                            .await);
492                    }
493                }
494                Ok(response) => {
495                    let status = response.status();
496                    let error = TransportError::RemoteError(
497                        status.as_u16(),
498                        read_error_body(response).await,
499                    );
500                    return Err(self
501                        .record_failure_for_peer(
502                            &signed,
503                            &body,
504                            &error,
505                            attempt.saturating_add(1),
506                            &peer,
507                        )
508                        .await);
509                }
510                Err(error) if error.is_timeout() && peer.retry_count == 0 => {
511                    return Err(self
512                        .record_failure_for_peer(
513                            &signed,
514                            &body,
515                            &TransportError::Timeout,
516                            attempt.saturating_add(1),
517                            &peer,
518                        )
519                        .await);
520                }
521                Err(_) if attempt == peer.retry_count => {
522                    let failure = TransportError::Exhausted(peer.retry_count);
523                    return Err(self
524                        .record_failure_for_peer(
525                            &signed,
526                            &body,
527                            &failure,
528                            attempt.saturating_add(1),
529                            &peer,
530                        )
531                        .await);
532                }
533                Err(_) => {}
534            }
535            tokio::time::sleep(Duration::from_millis(peer.retry_delay_ms)).await;
536        }
537        Err(TransportError::Exhausted(peer.retry_count))
538    }
539
540    async fn record_failure(
541        &self,
542        envelope: &TransportEnvelopeV1,
543        body: &[u8],
544        failure: TransportError,
545        attempts: u8,
546    ) -> TransportError {
547        let envelope = envelope.clone();
548        let body = body.to_vec();
549        let config = self.config.clone();
550        let failure_for_store = failure.clone();
551        let stored = tokio::task::spawn_blocking(move || {
552            enqueue_permanent_failure(&envelope, &body, &failure_for_store, attempts, &config)
553        })
554        .await;
555        match stored {
556            Ok(Ok(())) => failure,
557            Ok(Err(error)) => TransportError::DeadLetterFailure(format!("{failure}; {error}")),
558            Err(error) => TransportError::DeadLetterFailure(format!(
559                "{failure}; dead-letter worker failed: {error}"
560            )),
561        }
562    }
563
564    async fn ensure_dlq_available(&self) -> Result<(), TransportError> {
565        let scope = DlqScope::new(
566            self.config
567                .tenant_id
568                .clone()
569                .expect("validated tenant scope"),
570            self.config
571                .project_id
572                .clone()
573                .expect("validated project scope"),
574        )
575        .map_err(|error| TransportError::DeadLetterFailure(error.to_string()))?;
576        self.ensure_dlq_scope_inner(scope).await
577    }
578
579    async fn ensure_dlq_scope(
580        &self,
581        tenant_id: &str,
582        project_id: &str,
583    ) -> Result<(), TransportError> {
584        let scope = DlqScope::new(tenant_id, project_id)
585            .map_err(|error| TransportError::DeadLetterFailure(error.to_string()))?;
586        self.ensure_dlq_scope_inner(scope).await
587    }
588
589    async fn ensure_dlq_scope_inner(&self, scope: DlqScope) -> Result<(), TransportError> {
590        let queue = crate::core::ocla::health::dead_letter_queue().clone();
591        tokio::task::spawn_blocking(move || queue.ensure_writable(&scope))
592            .await
593            .map_err(|error| {
594                TransportError::DeadLetterFailure(format!("dead-letter worker failed: {error}"))
595            })?
596            .map_err(|error| TransportError::DeadLetterFailure(error.to_string()))
597    }
598
599    async fn record_failure_for_peer(
600        &self,
601        envelope: &TransportEnvelopeV1,
602        body: &[u8],
603        failure: &TransportError,
604        attempts: u8,
605        peer: &RelayPeerConfigV1,
606    ) -> TransportError {
607        let envelope = envelope.clone();
608        let body = body.to_vec();
609        let config = self.config.clone();
610        let failure_for_store = failure.clone();
611        let peer_id = peer.peer_id.clone();
612        let endpoint_url = peer.endpoint_url.clone();
613        let stored = tokio::task::spawn_blocking(move || {
614            enqueue_permanent_failure_for_peer(
615                &envelope,
616                &body,
617                &failure_for_store,
618                attempts,
619                &config,
620                &peer_id,
621                &endpoint_url,
622            )
623        })
624        .await;
625        match stored {
626            Ok(Ok(())) => failure.clone(),
627            Ok(Err(error)) => TransportError::DeadLetterFailure(format!("{failure}; {error}")),
628            Err(error) => TransportError::DeadLetterFailure(format!(
629                "{failure}; dead-letter worker failed: {error}"
630            )),
631        }
632    }
633
634    pub async fn retry_dead_letter(
635        &self,
636        letter: &DeadLetter,
637    ) -> Result<DeliveryReceipt, TransportError> {
638        if letter.peer_id != "legacy" {
639            let envelope: TransportEnvelopeV1 = serde_json::from_str(&letter.original_message)
640                .map_err(|error| TransportError::SerializationError(error.to_string()))?;
641            let record = envelope
642                .relay_record()
643                .map_err(TransportError::SerializationError)?
644                .ok_or_else(|| {
645                    TransportError::SerializationError(
646                        "peer dead letter is missing relay record".into(),
647                    )
648                })?;
649            if record.delivery_id != letter.delivery_id {
650                return Err(TransportError::SerializationError(
651                    "dead letter delivery ID does not match relay record".into(),
652                ));
653            }
654            if record.tenant_id != letter.tenant_id || record.project_id != letter.project_id {
655                return Err(TransportError::SerializationError(
656                    "dead letter scope does not match relay record".into(),
657                ));
658            }
659            let peer = self
660                .config
661                .peers
662                .iter()
663                .find(|peer| peer.peer_id == letter.peer_id)
664                .ok_or_else(|| {
665                    TransportError::SerializationError("dead letter peer is not configured".into())
666                })?;
667            if &peer.endpoint_url
668                != match &letter.delivery {
669                    DeadLetterDelivery::RemoteHttp { endpoint_url } => endpoint_url,
670                    DeadLetterDelivery::LocalAgentBus => {
671                        return Err(TransportError::SerializationError(
672                            "dead letter is not remote".into(),
673                        ));
674                    }
675                }
676            {
677                return Err(TransportError::SerializationError(
678                    "dead letter endpoint does not match peer".into(),
679                ));
680            }
681            let mut envelope = envelope;
682            envelope.sent_at = Utc::now();
683            envelope.signature = None;
684            return self.deliver_to(&letter.peer_id, &envelope).await;
685        }
686        let expected_scope = (
687            self.config.tenant_id.as_deref(),
688            self.config.project_id.as_deref(),
689        );
690        if expected_scope
691            != (
692                Some(letter.tenant_id.as_str()),
693                Some(letter.project_id.as_str()),
694            )
695        {
696            return Err(TransportError::SerializationError(
697                "dead letter scope does not match configured remote transport".into(),
698            ));
699        }
700        let DeadLetterDelivery::RemoteHttp { endpoint_url } = &letter.delivery else {
701            return Err(TransportError::SerializationError(
702                "dead letter is not a remote delivery".into(),
703            ));
704        };
705        if endpoint_url != &self.config.endpoint_url {
706            return Err(TransportError::SerializationError(
707                "dead letter endpoint does not match configured remote transport".into(),
708            ));
709        }
710        let mut envelope: TransportEnvelopeV1 = serde_json::from_str(&letter.original_message)
711            .map_err(|error| TransportError::SerializationError(error.to_string()))?;
712        if !envelope
713            .metadata
714            .contains_key(crate::core::a2a_transport::LEGACY_DELIVERY_ID_METADATA)
715            && let Some(signature) = envelope.signature.as_deref()
716        {
717            envelope.metadata.insert(
718                crate::core::a2a_transport::LEGACY_DELIVERY_ID_METADATA.to_string(),
719                signature.to_ascii_lowercase(),
720            );
721            envelope.metadata.insert(
722                crate::core::a2a_transport::LEGACY_DELIVERY_SENT_AT_METADATA.to_string(),
723                envelope.sent_at.to_rfc3339(),
724            );
725        }
726        envelope.sent_at = Utc::now();
727        envelope.signature = None;
728        self.deliver(&envelope).await
729    }
730}
731
732#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
733pub struct DeliveryReceipt {
734    pub envelope_id: String,
735    pub delivered_at: DateTime<Utc>,
736    pub remote_status: u16,
737    pub round_trip_ms: u64,
738    /// Bounded, complete UTF-8 task response. HTTP success is NOT evidence of
739    /// task authority: callers must verify its signature and request binding.
740    /// Absent for non-task deliveries and old serialized receipts.
741    #[serde(default, skip_serializing_if = "Option::is_none")]
742    pub unverified_task_response: Option<String>,
743}
744
745impl DeliveryReceipt {
746    /// Return only a status authenticated against the caller's trusted peer
747    /// policy and exact control request. Receipt metadata is not authority.
748    /// Re-check current time and revocations even for a deserialized receipt.
749    pub fn verified_task_status(
750        &self,
751        request: &super::task::TaskControlDescriptorV1,
752        policy: &super::task::TaskAuthorityConfigV1,
753        now: DateTime<Utc>,
754    ) -> Result<super::task::TaskStatusV1, super::task_response::InvalidTaskResponse> {
755        use super::task_response::{InvalidTaskResponse, SignedTaskStatusV1};
756
757        if !(200..300).contains(&self.remote_status) {
758            return Err(InvalidTaskResponse);
759        }
760        let raw = self
761            .unverified_task_response
762            .as_deref()
763            .ok_or(InvalidTaskResponse)?;
764        let response = SignedTaskStatusV1::from_json(raw)?;
765        response.verify(request, policy, now)?;
766        Ok(response.status)
767    }
768}
769
770async fn read_task_response(
771    response: reqwest::Response,
772    envelope: &TransportEnvelopeV1,
773) -> Result<Option<String>, TransportError> {
774    if envelope.content_type != crate::core::a2a_transport::TransportContentType::A2ATask {
775        return Ok(None);
776    }
777    let invalid =
778        || TransportError::SerializationError("invalid or oversized task response".into());
779    if response
780        .content_length()
781        .is_some_and(|length| length > MAX_TASK_RESPONSE_BYTES as u64)
782    {
783        return Err(invalid());
784    }
785    let mut stream = response.bytes_stream();
786    let mut bytes = Vec::new();
787    while let Some(chunk) = stream.next().await {
788        let chunk = chunk.map_err(|_| invalid())?;
789        if chunk.len() > MAX_TASK_RESPONSE_BYTES.saturating_sub(bytes.len()) {
790            return Err(invalid());
791        }
792        bytes.extend_from_slice(&chunk);
793    }
794    if bytes.is_empty() {
795        return Err(invalid());
796    }
797    String::from_utf8(bytes).map(Some).map_err(|_| invalid())
798}
799
800#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, thiserror::Error)]
801pub enum TransportError {
802    #[error("payload too large: {0} bytes")]
803    PayloadTooLarge(usize),
804    #[error("transport timed out")]
805    Timeout,
806    #[error("remote returned HTTP {0}: {1}")]
807    RemoteError(u16, String),
808    #[error("serialization failed: {0}")]
809    SerializationError(String),
810    #[error("delivery exhausted after {0} retries")]
811    Exhausted(u8),
812    #[error("delivery failed and could not be persisted to DLQ: {0}")]
813    DeadLetterFailure(String),
814}
815
816fn serialize_and_validate(
817    envelope: &TransportEnvelopeV1,
818    max_payload_bytes: usize,
819) -> Result<Vec<u8>, TransportError> {
820    let body = serde_json::to_vec(envelope)
821        .map_err(|error| TransportError::SerializationError(error.to_string()))?;
822    if body.len() > max_payload_bytes {
823        return Err(TransportError::PayloadTooLarge(body.len()));
824    }
825    Ok(body)
826}
827
828fn peer_delivery_url(peer: &RelayPeerConfigV1) -> Result<reqwest::Url, String> {
829    let mut url = reqwest::Url::parse(&peer.endpoint_url)
830        .map_err(|error| format!("invalid peer endpoint_url: {error}"))?;
831    let path = format!("{}/a2a/deliver", url.path().trim_end_matches('/'));
832    url.set_path(&path);
833    url.set_query(None);
834    url.set_fragment(None);
835    Ok(url)
836}
837
838fn envelope_id(body: &[u8]) -> String {
839    format!("envelope:{}", blake3::hash(body).to_hex())
840}
841
842fn elapsed_millis(started_at: Instant) -> u64 {
843    u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX)
844}
845
846async fn read_error_body(response: reqwest::Response) -> String {
847    let mut stream = response.bytes_stream();
848    let mut body = Vec::new();
849    while let Some(chunk) = stream.next().await {
850        let Ok(chunk) = chunk else {
851            break;
852        };
853        let remaining = MAX_ERROR_BODY_BYTES.saturating_sub(body.len());
854        body.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
855        if body.len() == MAX_ERROR_BODY_BYTES {
856            break;
857        }
858    }
859    String::from_utf8_lossy(&body).into_owned()
860}
861
862fn enqueue_permanent_failure(
863    envelope: &TransportEnvelopeV1,
864    body: &[u8],
865    failure: &TransportError,
866    attempts: u8,
867    config: &RemoteTransportConfig,
868) -> crate::core::ocla::types::OclaResult<()> {
869    let peer_id = config.peer_id.as_deref().unwrap_or("legacy");
870    enqueue_permanent_failure_for_peer(
871        envelope,
872        body,
873        failure,
874        attempts,
875        config,
876        peer_id,
877        &config.endpoint_url,
878    )
879}
880
881fn enqueue_permanent_failure_for_peer(
882    envelope: &TransportEnvelopeV1,
883    body: &[u8],
884    failure: &TransportError,
885    attempts: u8,
886    config: &RemoteTransportConfig,
887    peer_id: &str,
888    endpoint_url: &str,
889) -> crate::core::ocla::types::OclaResult<()> {
890    let failed_at = Utc::now().to_rfc3339();
891    let target_agent = envelope.recipient.as_deref().unwrap_or(endpoint_url);
892    let mut error = failure.to_string();
893    if error.len() > MAX_ERROR_BYTES {
894        let mut boundary = MAX_ERROR_BYTES;
895        while !error.is_char_boundary(boundary) {
896            boundary -= 1;
897        }
898        error.truncate(boundary);
899    }
900    let relay_record = envelope.relay_record().ok().flatten();
901    let delivery_id = relay_record
902        .as_ref()
903        .map(|record| record.delivery_id.clone())
904        .or_else(|| envelope.stable_delivery_id().ok())
905        .unwrap_or_else(|| envelope_id(body));
906    let tenant_id = relay_record
907        .as_ref()
908        .map(|record| record.tenant_id.clone())
909        .or_else(|| config.tenant_id.clone())
910        .expect("validated tenant scope");
911    let project_id = relay_record
912        .as_ref()
913        .map(|record| record.project_id.clone())
914        .or_else(|| config.project_id.clone())
915        .expect("validated project scope");
916    crate::core::ocla::health::dead_letter_queue().enqueue(DeadLetter {
917        id: envelope_id(body),
918        peer_id: peer_id.to_string(),
919        delivery_id,
920        tenant_id,
921        project_id,
922        delivery: DeadLetterDelivery::RemoteHttp {
923            endpoint_url: endpoint_url.to_string(),
924        },
925        original_message: String::from_utf8_lossy(body).into_owned(),
926        target_agent: target_agent.to_string(),
927        error,
928        attempts,
929        first_failed_at: failed_at.clone(),
930        last_failed_at: failed_at,
931    })
932}
933
934#[cfg(test)]
935mod tests {
936    use std::collections::HashMap;
937
938    #[cfg(feature = "http-server")]
939    use axum::{Router, http::StatusCode, routing::post};
940
941    use super::*;
942
943    #[tokio::test]
944    async fn relay_peer_mode_has_no_singleton_authority_or_fallback() {
945        let peer = RelayPeerConfigV1 {
946            schema_version: 1,
947            peer_id: "destination".into(),
948            endpoint_url: "https://destination.example".into(),
949            bearer_token: "destination-bearer".into(),
950            channel_key: "destination-channel".into(),
951            origin_public_key: crate::core::a2a::relay::test_origin_public_key("destination"),
952            recipient_id: "recipient".into(),
953            allowed_tenant_ids: vec!["tenant".into()],
954            allowed_project_ids: vec!["project".into()],
955            allowed_content_types: vec![
956                crate::core::a2a_transport::TransportContentType::EvidenceBundle,
957            ],
958            allowed_classifications: vec![lean_ctx_protocol::DataClassification::Internal],
959            max_hops: 4,
960            max_payload_bytes: 1024,
961            retry_count: 0,
962            retry_delay_ms: 0,
963        };
964        let transport = RemoteTransport::for_peer_table(
965            RelayPeerTableV1 {
966                schema_version: 1,
967                peers: vec![peer],
968            },
969            "origin",
970            Duration::from_secs(5),
971            false,
972        )
973        .unwrap();
974        assert!(
975            transport.config.endpoint_url.is_empty(),
976            "{:?}",
977            transport.config.endpoint_url
978        );
979        assert!(transport.config.auth_token.is_none());
980        assert!(transport.config.signing_key.is_none());
981        assert!(transport.config.recipient_id.is_none());
982        assert!(transport.config.tenant_id.is_none());
983        assert!(transport.config.project_id.is_none());
984        let restored: RemoteTransport =
985            serde_json::from_str(&serde_json::to_string(&transport).unwrap()).unwrap();
986        restored.config.validate().unwrap();
987        for current in [&transport, &restored] {
988            assert!(matches!(current.deliver(&envelope("payload")).await,
989                Err(TransportError::SerializationError(message))
990                    if message == "peer transport requires explicit peer delivery"));
991        }
992        let mut mixed = transport.config.clone();
993        mixed.auth_token = Some("legacy-bearer".into());
994        assert!(RemoteTransport::new(mixed).is_err());
995        let mut incomplete = transport.config.clone();
996        incomplete.local_peer_id = None;
997        assert!(RemoteTransport::new(incomplete).is_err());
998        let mut empty = transport.config.clone();
999        empty.peers.clear();
1000        assert!(RemoteTransport::new(empty).is_err());
1001        let mut malformed = restored;
1002        malformed.config.timeout = Duration::ZERO;
1003        assert!(matches!(
1004            malformed
1005                .deliver_to("destination", &envelope("payload"))
1006                .await,
1007            Err(TransportError::SerializationError(_))
1008        ));
1009    }
1010    use crate::core::a2a_transport::{AgentIdentityV1, TransportContentType};
1011
1012    #[cfg(feature = "http-server")]
1013    #[tokio::test]
1014    async fn relay_clients_do_not_follow_redirects_after_deserialization() {
1015        use std::sync::{
1016            Arc,
1017            atomic::{AtomicUsize, Ordering},
1018        };
1019        let hits = Arc::new(AtomicUsize::new(0));
1020        let received = hits.clone();
1021        let target_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1022        let target_url = format!("http://{}/sink", target_listener.local_addr().unwrap());
1023        let target = tokio::spawn(async move {
1024            axum::serve(
1025                target_listener,
1026                Router::new().route(
1027                    "/sink",
1028                    post(move || {
1029                        let received = received.clone();
1030                        async move {
1031                            received.fetch_add(1, Ordering::SeqCst);
1032                            StatusCode::OK
1033                        }
1034                    }),
1035                ),
1036            )
1037            .await
1038            .unwrap();
1039        });
1040        for status in [
1041            StatusCode::TEMPORARY_REDIRECT,
1042            StatusCode::PERMANENT_REDIRECT,
1043        ] {
1044            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1045            let endpoint = format!("http://{}", listener.local_addr().unwrap());
1046            let location = target_url.clone();
1047            let source = tokio::spawn(async move {
1048                axum::serve(
1049                    listener,
1050                    Router::new().route(
1051                        "/a2a/deliver",
1052                        post(move || {
1053                            let location = location.clone();
1054                            async move { (status, [(axum::http::header::LOCATION, location)]) }
1055                        }),
1056                    ),
1057                )
1058                .await
1059                .unwrap();
1060            });
1061            let original = RemoteTransport::new(RemoteTransportConfig {
1062                endpoint_url: endpoint.clone(),
1063                auth_token: Some("bearer".into()),
1064                signing_key: Some("signing-key".into()),
1065                recipient_id: Some("recipient".into()),
1066                tenant_id: Some("tenant".into()),
1067                project_id: Some("project".into()),
1068                ..RemoteTransportConfig::default()
1069            })
1070            .unwrap();
1071            let restored: RemoteTransport =
1072                serde_json::from_str(&serde_json::to_string(&original).unwrap()).unwrap();
1073            for transport in [original, restored] {
1074                let response = transport
1075                    .client
1076                    .post(format!("{endpoint}/a2a/deliver"))
1077                    .timeout(Duration::from_secs(5))
1078                    .body("private context")
1079                    .send()
1080                    .await
1081                    .unwrap();
1082                assert_eq!(response.status(), status);
1083                assert_eq!(hits.load(Ordering::SeqCst), 0);
1084            }
1085            source.abort();
1086            let _ = source.await;
1087        }
1088        target.abort();
1089        let _ = target.await;
1090    }
1091
1092    fn envelope(payload: &str) -> TransportEnvelopeV1 {
1093        TransportEnvelopeV1 {
1094            format_version: 1,
1095            sent_at: Utc::now(),
1096            sender: AgentIdentityV1 {
1097                agent_id: "sender".to_string(),
1098                agent_type: "test".to_string(),
1099                daemon_fingerprint: "fingerprint".to_string(),
1100                capabilities: Vec::new(),
1101            },
1102            recipient: Some("recipient".to_string()),
1103            content_type: TransportContentType::A2AMessage,
1104            payload_json: payload.to_string(),
1105            signature: None,
1106            metadata: HashMap::new(),
1107        }
1108    }
1109
1110    #[test]
1111    fn default_config_has_bounded_transport_values() {
1112        let config = RemoteTransportConfig {
1113            endpoint_url: "https://agent.example/api/".to_string(),
1114            auth_token: Some("secret".to_string()),
1115            signing_key: Some("signing-secret".to_string()),
1116            recipient_id: Some("recipient".to_string()),
1117            tenant_id: Some("tenant-a".to_string()),
1118            project_id: Some("project-a".to_string()),
1119            ..RemoteTransportConfig::default()
1120        };
1121
1122        assert!(config.validate().is_ok());
1123        assert_eq!(config.timeout, Duration::from_secs(30));
1124        assert_eq!(config.max_payload_bytes, 2_000_000);
1125        assert_eq!(config.retry_count, 2);
1126        assert_eq!(config.retry_delay, Duration::from_secs(1));
1127        assert_eq!(
1128            config.delivery_url().expect("valid URL").as_str(),
1129            "https://agent.example/api/a2a/deliver"
1130        );
1131        for peer_id in ["p", "legacy", ""] {
1132            let mut mixed = config.clone();
1133            mixed.peer_id = Some(peer_id.into());
1134            let restored: RemoteTransportConfig =
1135                serde_json::from_str(&serde_json::to_string(&mixed).unwrap()).unwrap();
1136            assert!(restored.validate().unwrap_err().contains("peer_id"));
1137            assert!(RemoteTransport::new(restored).is_err());
1138        }
1139    }
1140
1141    #[cfg(feature = "http-server")]
1142    #[tokio::test]
1143    async fn task_response_is_complete_bounded_utf8_and_explicitly_unverified() {
1144        let mut task = envelope("{}");
1145        task.content_type = TransportContentType::A2ATask;
1146        for size in [1, MAX_TASK_RESPONSE_BYTES] {
1147            let body = "x".repeat(size);
1148            let response = reqwest::Response::from(axum::http::Response::new(body.clone()));
1149            assert_eq!(
1150                read_task_response(response, &task).await.unwrap(),
1151                Some(body)
1152            );
1153        }
1154        for body in [
1155            Vec::new(),
1156            vec![b'x'; MAX_TASK_RESPONSE_BYTES + 1],
1157            vec![0xff],
1158        ] {
1159            let response = reqwest::Response::from(axum::http::Response::new(body));
1160            assert!(read_task_response(response, &task).await.is_err());
1161        }
1162        let response = reqwest::Response::from(axum::http::Response::new(vec![0xff]));
1163        assert_eq!(
1164            read_task_response(response, &envelope("{}")).await.unwrap(),
1165            None
1166        );
1167    }
1168
1169    #[cfg(feature = "http-server")]
1170    #[tokio::test]
1171    async fn interrupted_task_response_is_not_accepted_as_complete() {
1172        let mut task = envelope("{}");
1173        task.content_type = TransportContentType::A2ATask;
1174        let chunks = futures::stream::iter([
1175            Ok::<_, std::io::Error>(vec![b'{']),
1176            Err(std::io::Error::other("interrupted response")),
1177        ]);
1178        let response = reqwest::Response::from(axum::http::Response::new(
1179            reqwest::Body::wrap_stream(chunks),
1180        ));
1181        assert!(read_task_response(response, &task).await.is_err());
1182    }
1183
1184    #[test]
1185    fn config_validation_rejects_unbounded_or_unsupported_values() {
1186        let zero_timeout = RemoteTransportConfig {
1187            endpoint_url: "https://agent.example".to_string(),
1188            timeout: Duration::ZERO,
1189            auth_token: Some("secret".to_string()),
1190            signing_key: Some("signing-secret".to_string()),
1191            ..RemoteTransportConfig::default()
1192        };
1193        let unsupported_scheme = RemoteTransportConfig {
1194            endpoint_url: "file:///tmp/agent".to_string(),
1195            auth_token: Some("secret".to_string()),
1196            signing_key: Some("signing-secret".to_string()),
1197            ..RemoteTransportConfig::default()
1198        };
1199
1200        assert!(zero_timeout.validate().is_err());
1201        assert!(unsupported_scheme.validate().is_err());
1202        let insecure_remote = RemoteTransportConfig {
1203            endpoint_url: "http://agent.example".to_string(),
1204            auth_token: Some("bearer-secret".to_string()),
1205            signing_key: Some("signing-secret".to_string()),
1206            recipient_id: Some("recipient".to_string()),
1207            tenant_id: Some("tenant-a".to_string()),
1208            project_id: Some("project-a".to_string()),
1209            ..RemoteTransportConfig::default()
1210        };
1211        assert_eq!(
1212            insecure_remote.validate(),
1213            Err("endpoint_url must use HTTPS except for loopback hosts".to_string())
1214        );
1215        assert!(
1216            RemoteTransportConfig {
1217                endpoint_url: "http://[::1]:8080".to_string(),
1218                ..insecure_remote
1219            }
1220            .validate()
1221            .is_ok()
1222        );
1223        assert!(
1224            RemoteTransportConfig {
1225                endpoint_url: "https://user:secret@agent.example".to_string(),
1226                auth_token: Some("secret".to_string()),
1227                signing_key: Some("signing-secret".to_string()),
1228                recipient_id: Some("recipient".to_string()),
1229                tenant_id: Some("tenant-a".to_string()),
1230                project_id: Some("project-a".to_string()),
1231                ..RemoteTransportConfig::default()
1232            }
1233            .validate()
1234            .is_err()
1235        );
1236        assert!(
1237            RemoteTransportConfig {
1238                endpoint_url: "https://agent.example".to_string(),
1239                ..RemoteTransportConfig::default()
1240            }
1241            .validate()
1242            .is_err()
1243        );
1244        assert_eq!(
1245            RemoteTransportConfig {
1246                endpoint_url: "https://agent.example".to_string(),
1247                auth_token: Some("same-secret".to_string()),
1248                signing_key: Some("same-secret".to_string()),
1249                ..RemoteTransportConfig::default()
1250            }
1251            .validate(),
1252            Err("signing_key must be distinct from auth_token".to_string())
1253        );
1254    }
1255
1256    #[tokio::test]
1257    async fn delivery_rejects_recipient_outside_configured_authority() {
1258        let transport = RemoteTransport::new(RemoteTransportConfig {
1259            endpoint_url: "http://127.0.0.1:9".to_string(),
1260            auth_token: Some("bearer-secret".to_string()),
1261            signing_key: Some("signing-secret".to_string()),
1262            recipient_id: Some("different-recipient".to_string()),
1263            tenant_id: Some("tenant-a".to_string()),
1264            project_id: Some("project-a".to_string()),
1265            retry_count: 0,
1266            ..RemoteTransportConfig::default()
1267        })
1268        .expect("valid transport config");
1269
1270        assert_eq!(
1271            transport.deliver(&envelope("payload")).await,
1272            Err(TransportError::SerializationError(
1273                "envelope recipient does not match configured remote recipient".to_string()
1274            ))
1275        );
1276    }
1277
1278    #[cfg(feature = "http-server")]
1279    #[tokio::test]
1280    async fn remote_retry_refreshes_timestamp_and_signature_for_authority_receiver() {
1281        let signing_key = "retry-signing-secret";
1282        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1283        let address = listener.local_addr().unwrap();
1284        let app = Router::new().route(
1285            "/a2a/deliver",
1286            post(move |body: axum::body::Bytes| async move {
1287                let Ok(envelope) = serde_json::from_slice::<TransportEnvelopeV1>(&body) else {
1288                    return StatusCode::BAD_REQUEST;
1289                };
1290                let age = Utc::now().signed_duration_since(envelope.sent_at);
1291                if age > chrono::Duration::seconds(300)
1292                    || age < chrono::Duration::seconds(-30)
1293                    || !envelope.verify_signature(signing_key.as_bytes())
1294                {
1295                    StatusCode::UNAUTHORIZED
1296                } else {
1297                    StatusCode::OK
1298                }
1299            }),
1300        );
1301        let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1302        let transport = RemoteTransport::new(RemoteTransportConfig {
1303            endpoint_url: format!("http://{address}"),
1304            auth_token: Some("bearer-secret".into()),
1305            signing_key: Some(signing_key.into()),
1306            recipient_id: Some("recipient".into()),
1307            tenant_id: Some("tenant-a".into()),
1308            project_id: Some("project-a".into()),
1309            retry_count: 0,
1310            ..RemoteTransportConfig::default()
1311        })
1312        .unwrap();
1313        let mut stale = envelope("payload");
1314        stale.sent_at = Utc::now() - chrono::Duration::minutes(10);
1315        stale.sign(signing_key.as_bytes()).unwrap();
1316        let letter = DeadLetter {
1317            id: "stale-letter".into(),
1318            peer_id: "legacy".into(),
1319            delivery_id: stale.stable_delivery_id().unwrap(),
1320            tenant_id: "tenant-a".into(),
1321            project_id: "project-a".into(),
1322            delivery: DeadLetterDelivery::RemoteHttp {
1323                endpoint_url: format!("http://{address}"),
1324            },
1325            original_message: serde_json::to_string(&stale).unwrap(),
1326            target_agent: "recipient".into(),
1327            error: "previous failure".into(),
1328            attempts: 1,
1329            first_failed_at: Utc::now().to_rfc3339(),
1330            last_failed_at: Utc::now().to_rfc3339(),
1331        };
1332
1333        assert_eq!(
1334            transport
1335                .retry_dead_letter(&letter)
1336                .await
1337                .unwrap()
1338                .remote_status,
1339            StatusCode::OK.as_u16()
1340        );
1341        server.abort();
1342    }
1343
1344    #[cfg(feature = "http-server")]
1345    #[tokio::test]
1346    async fn permanent_remote_failure_is_persisted_with_exact_scope() {
1347        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1348        let address = listener.local_addr().unwrap();
1349        let app = Router::new().route(
1350            "/a2a/deliver",
1351            post(|| async { (StatusCode::BAD_REQUEST, "scope denied") }),
1352        );
1353        let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1354        let suffix = uuid::Uuid::new_v4().to_string();
1355        let tenant_id = format!("tenant-{suffix}");
1356        let project_id = format!("project-{suffix}");
1357        let scope = crate::core::a2a::dlq::DlqScope::new(&tenant_id, &project_id).unwrap();
1358        let transport = RemoteTransport::new(RemoteTransportConfig {
1359            endpoint_url: format!("http://{address}"),
1360            auth_token: Some("bearer-secret".into()),
1361            signing_key: Some("signing-secret".into()),
1362            recipient_id: Some("recipient".into()),
1363            tenant_id: Some(tenant_id),
1364            project_id: Some(project_id),
1365            retry_count: 0,
1366            ..RemoteTransportConfig::default()
1367        })
1368        .unwrap();
1369
1370        assert!(matches!(
1371            transport.deliver(&envelope("payload")).await,
1372            Err(TransportError::RemoteError(400, _))
1373        ));
1374        let entries = crate::core::ocla::health::dead_letter_queue()
1375            .peek(&scope)
1376            .unwrap();
1377        assert_eq!(entries.len(), 1);
1378        assert_eq!(entries[0].target_agent, "recipient");
1379        assert!(matches!(
1380            entries[0].delivery,
1381            DeadLetterDelivery::RemoteHttp { .. }
1382        ));
1383        server.abort();
1384    }
1385
1386    #[test]
1387    fn payload_size_limit_reports_serialized_size() {
1388        let envelope = envelope("payload");
1389        let serialized = serde_json::to_vec(&envelope).expect("serializable envelope");
1390        let error = serialize_and_validate(&envelope, serialized.len() - 1)
1391            .expect_err("payload must exceed configured limit");
1392
1393        assert_eq!(error, TransportError::PayloadTooLarge(serialized.len()));
1394    }
1395
1396    #[test]
1397    fn transport_error_variants_are_serializable_and_distinct() {
1398        let variants = [
1399            TransportError::PayloadTooLarge(10),
1400            TransportError::Timeout,
1401            TransportError::RemoteError(400, "bad request".to_string()),
1402            TransportError::SerializationError("invalid JSON".to_string()),
1403            TransportError::Exhausted(2),
1404            TransportError::DeadLetterFailure("store unavailable".to_string()),
1405        ];
1406
1407        for variant in variants {
1408            let json = serde_json::to_string(&variant).expect("serialize error");
1409            let decoded = serde_json::from_str(&json).expect("deserialize error");
1410            assert_eq!(variant, decoded);
1411        }
1412    }
1413
1414    #[test]
1415    fn receipt_round_trips_without_losing_delivery_fields() {
1416        let receipt = DeliveryReceipt {
1417            envelope_id: "envelope:abc".to_string(),
1418            delivered_at: Utc::now(),
1419            remote_status: 202,
1420            round_trip_ms: 17,
1421            unverified_task_response: None,
1422        };
1423
1424        let json = serde_json::to_string(&receipt).expect("serialize receipt");
1425        let decoded: DeliveryReceipt = serde_json::from_str(&json).expect("deserialize receipt");
1426        assert_eq!(decoded, receipt);
1427    }
1428
1429    #[test]
1430    fn envelope_ids_are_deterministic_and_content_addressed() {
1431        let first = serialize_and_validate(&envelope("one"), usize::MAX).expect("serialize");
1432        let first_again = first.clone();
1433        let second = serialize_and_validate(&envelope("two"), usize::MAX).expect("serialize");
1434
1435        assert_eq!(envelope_id(&first), envelope_id(&first_again));
1436        assert_ne!(envelope_id(&first), envelope_id(&second));
1437    }
1438}