Skip to main content

turul_a2a_aws_lambda/
lib.rs

1//! AWS Lambda adapter for turul-a2a.
2//!
3//! # Choosing a runner
4//!
5//! A Lambda binary receives raw event JSON; the adapter owns
6//! classifying that JSON and dispatching to the right A2A handler
7//! method. Adopters pick the runner whose name matches the Lambda's
8//! AWS trigger topology and end `main.rs` with one `.await?` call —
9//! `lambda_runtime` / `lambda_http` / event parsing never appear in
10//! adopter code.
11//!
12//! | Lambda triggers                   | Runner                                         |
13//! |-----------------------------------|------------------------------------------------|
14//! | HTTP only (Function URL / APIGW)  | [`LambdaA2aHandler::run_http_only`]            |
15//! | SQS only (event source mapping)   | `LambdaA2aHandler::run_sqs_only` *(`sqs`)*   |
16//! | HTTP **and** SQS on one function  | `LambdaA2aHandler::run_http_and_sqs` *(`sqs`)* |
17//! | not sure / just run it            | [`LambdaA2aHandler::run`]                      |
18//!
19//! [`LambdaA2aHandler::run`] is the default "it just works" entry
20//! point: without the `sqs` feature it aliases `run_http_only`; with
21//! `sqs` it aliases `run_http_and_sqs` (permissive, handles either
22//! shape). The explicit runners are **strict** — a non-matching
23//! event shape fails loudly, which is what hardened deployments
24//! and tests want.
25//!
26//! The one-call shortcut is [`LambdaA2aServerBuilder::run`] — it
27//! build-then-runs in a single fluent call. Use `.build()` + a
28//! handler method when the handler is needed for unit tests or
29//! custom middleware.
30//!
31//! ## Composing with non-framework triggers
32//!
33//! Lambdas wired to HTTP **plus** a third trigger the framework does
34//! not know about (EventBridge scheduler, DynamoDB stream, custom
35//! invoke) can't use a named runner directly — the Unknown branch
36//! is adopter-owned. Reach for the public classifier + the HTTP
37//! envelope primitive instead:
38//!
39//! ```ignore
40//! lambda_runtime::run(lambda_runtime::service_fn(
41//!     move |event: lambda_runtime::LambdaEvent<serde_json::Value>| {
42//!         let handler = handler.clone();
43//!         async move {
44//!             let (value, _ctx) = event.into_parts();
45//!             match turul_a2a_aws_lambda::classify_event(&value) {
46//!                 turul_a2a_aws_lambda::LambdaEvent::Http => {
47//!                     handler.handle_http_event_value(value).await
48//!                 }
49//!                 turul_a2a_aws_lambda::LambdaEvent::Sqs => {
50//!                     let sqs = serde_json::from_value(value)?;
51//!                     Ok(serde_json::to_value(handler.handle_sqs(sqs).await)?)
52//!                 }
53//!                 turul_a2a_aws_lambda::LambdaEvent::Unknown => {
54//!                     // Adopter-owned: run your scheduled sweep,
55//!                     // DynamoDB stream consumer, etc.
56//!                     run_adopter_specific_path(value).await
57//!                 }
58//!             }
59//!         }
60//!     }
61//! )).await
62//! ```
63//!
64//! [`LambdaA2aHandler::handle_http_event_value`] is available without
65//! the `sqs` feature; `handle_sqs` is gated on `sqs`. `classify_event`
66//! + `LambdaEvent` are always available.
67//!
68//! ## API Gateway path prefix
69//!
70//! When Lambda sits behind a REST API Gateway with `AWS_PROXY`
71//! integration, the event carries the full stage + resource tree in
72//! the path (e.g. `/stage/agent/message:send`),
73//! which the root-rooted A2A router would 404. Configure the prefix
74//! to strip via [`LambdaA2aServerBuilder::strip_path_prefix`]:
75//!
76//! ```ignore
77//! LambdaA2aServerBuilder::new()
78//!     .executor(my_executor)
79//!     .storage(my_storage)
80//!     .strip_path_prefix("/stage/agent")  // API GW prefix
81//!     .run()
82//!     .await
83//! ```
84//!
85//! The strip applies to `run_http_only` and `run_http_and_sqs`.
86//! SQS events are unaffected. Non-matching paths pass through
87//! unchanged (router still 404s on genuinely unknown paths —
88//! that's the correct failure mode).
89//!
90//! ```ignore
91//! // HTTP-only Lambda (request Lambda in two-Lambda topology):
92//! LambdaA2aServerBuilder::new()
93//!     .executor(my_executor)
94//!     .storage(my_storage)
95//!     .with_sqs_return_immediately(queue_url, sqs)  // enqueues durable jobs
96//!     .build()?
97//!     .run_http_only()
98//!     .await
99//!
100//! // Single-function HTTP+SQS demo:
101//! LambdaA2aServerBuilder::new()
102//!     .executor(my_executor)
103//!     .storage(my_storage)
104//!     .with_sqs_return_immediately(queue_url, sqs)
105//!     .run()            // builder shortcut; dispatches HTTP + SQS
106//!     .await
107//!
108//! // Pure SQS worker — no with_sqs_return_immediately(...) needed
109//! // (it consumes the queue, never enqueues):
110//! LambdaA2aServerBuilder::new()
111//!     .executor(my_executor)
112//!     .storage(my_storage)
113//!     .build()?
114//!     .run_sqs_only()
115//!     .await
116//! ```
117//!
118//! # Adapter internals
119//!
120//! Thin wrapper: converts Lambda events to axum requests, delegates to
121//! the same Router, converts responses back. Adapter contract:
122//! - Authorizer context mapped via synthetic headers with anti-spoofing.
123//! - SSE responses are buffered: task executes, events are collected,
124//!   returned as one response.
125//! - `POST /message:stream` executes the task within the Lambda
126//!   invocation and returns all events.
127//! - `GET /tasks/{id}:subscribe` is for tasks that are **not** in a
128//!   terminal state. Within one invocation it emits the initial `Task`
129//!   snapshot, delivers events committed after it, and closes when the
130//!   task reaches a terminal state or INPUT_REQUIRED. Subscribing to an
131//!   already-terminal task returns `UnsupportedOperationError` per
132//!   A2A v1.0 §3.1.6. For retrieving a terminal task's final state
133//!   use `GetTask`.
134//!
135//! # Push-notification delivery — external triggers are mandatory
136//!
137//! Push delivery on Lambda is architecturally different from the
138//! binary server. The request Lambda installed via
139//! [`LambdaA2aServerBuilder`] still constructs a `PushDispatcher`
140//! when `push_delivery_store` is wired, but any `tokio::spawn`
141//! continuation it emits post-return is **opportunistic only** — the
142//! Lambda execution environment may be frozen indefinitely between
143//! invocations, so nothing can depend on that continuation completing.
144//!
145//! Correctness for push delivery on Lambda is carried by:
146//!
147//! 1. The atomic pending-dispatch marker written inside the request
148//!    Lambda's commit transaction (opt in via
149//!    `StorageImpl::with_push_dispatch_enabled(true)`).
150//! 2. [`LambdaStreamRecoveryHandler`] — DynamoDB Streams trigger on
151//!    `a2a_push_pending_dispatches`. DynamoDB backends only.
152//! 3. [`LambdaScheduledRecoveryHandler`] — EventBridge Scheduler
153//!    backstop. **Required for all backends**; it is the sole
154//!    recovery path for SQLite / PostgreSQL / in-memory deployments.
155//!
156//! Without at least the scheduled worker, push delivery on Lambda is
157//! not durable — a marker written on a cold invocation may never be
158//! consumed. The example wiring lives in
159//! `examples/lambda-stream-worker` and
160//! `examples/lambda-scheduled-worker`.
161//!
162//! Lambda streaming is request-scoped (not persistent SSE connections). The durable
163//! event store ensures events survive across invocations. A client that loses
164//! its stream subscribes again and receives a fresh `Task` snapshot.
165//!
166//! # Cross-instance cancellation
167//!
168//! Lambda invocations are stateless and short-lived, so the Lambda
169//! adapter does **not** run the persistent cross-instance cancel
170//! poller that `A2aServer::run()` spawns. Cancellation behaviour on
171//! the Lambda adapter:
172//!
173//! - **Marker writes** — `CancelTask` on a Lambda invocation writes
174//!   the cancel marker to the shared backend (DynamoDB / PostgreSQL).
175//!   This works.
176//! - **Propagation to a live executor on the SAME Lambda invocation** —
177//!   works via the same-instance token-trip path in `core_cancel_task`.
178//! - **Propagation to a live executor on a DIFFERENT Lambda invocation
179//!   (warm container)** — **not currently supported**. There is no
180//!   persistent poller to observe markers written by other
181//!   invocations. A subsequent invocation whose handler reads the
182//!   marker directly may act on it, but that is not a substitute for
183//!   the server runtime's live propagation.
184//!
185//! The builder still **requires** an `A2aCancellationSupervisor`
186//! implementation on the same backend so that marker writes reach
187//! the correct backend and a future polling-adapter variant can
188//! consume them. If a deployment passes a non-matching supervisor,
189//! `build()` rejects the configuration.
190
191mod adapter;
192mod auth;
193mod event;
194mod no_streaming;
195mod scheduled_recovery;
196mod stream_recovery;
197
198#[cfg(feature = "sqs")]
199mod durable;
200
201#[cfg(test)]
202mod builder_tests;
203#[cfg(test)]
204mod scheduled_recovery_tests;
205#[cfg(test)]
206mod stream_recovery_tests;
207
208pub use adapter::{axum_to_lambda_response, lambda_to_axum_request};
209pub use auth::{AuthorizerMapping, LambdaAuthorizerMiddleware};
210pub use event::{LambdaEvent, classify_event};
211pub use no_streaming::NoStreamingLayer;
212pub use scheduled_recovery::{
213    LambdaScheduledRecoveryConfig, LambdaScheduledRecoveryHandler, LambdaScheduledRecoveryResponse,
214    handle_maintenance_trigger,
215};
216pub use stream_recovery::LambdaStreamRecoveryHandler;
217
218#[cfg(feature = "sqs")]
219pub use durable::{SqsDurableExecutorQueue, drive_sqs_batch};
220
221use std::sync::Arc;
222
223use turul_a2a::error::A2aError;
224use turul_a2a::executor::AgentExecutor;
225use turul_a2a::middleware::{A2aMiddleware, MiddlewareStack};
226use turul_a2a::push::A2aPushDeliveryStore;
227use turul_a2a::router::{AppState, build_router};
228use turul_a2a::server::RuntimeConfig;
229use turul_a2a::storage::{
230    A2aAtomicStore, A2aCancellationSupervisor, A2aEventStore, A2aPushNotificationStorage,
231    A2aTaskStorage,
232};
233use turul_a2a::streaming::TaskEventBroker;
234
235/// Builder for Lambda A2A handler.
236///
237/// Use `.storage(my_storage)` to supply a single backend implementing all storage traits.
238/// All four storage traits must share the same backend so streaming, push delivery,
239/// and cross-instance cancellation coordinate correctly.
240///
241/// For push-notification delivery, wire `.storage()` with a backend that implements
242/// [`A2aPushDeliveryStore`] (all four first-party backends do). The atomic store
243/// must also opt in via `with_push_dispatch_enabled(true)`; the
244/// builder rejects configurations where the flag and the delivery store disagree.
245pub struct LambdaA2aServerBuilder {
246    executor: Option<Arc<dyn AgentExecutor>>,
247    task_storage: Option<Arc<dyn A2aTaskStorage>>,
248    push_storage: Option<Arc<dyn A2aPushNotificationStorage>>,
249    event_store: Option<Arc<dyn A2aEventStore>>,
250    atomic_store: Option<Arc<dyn A2aAtomicStore>>,
251    cancellation_supervisor: Option<Arc<dyn A2aCancellationSupervisor>>,
252    push_delivery_store: Option<Arc<dyn A2aPushDeliveryStore>>,
253    middleware: Vec<Arc<dyn A2aMiddleware>>,
254    runtime_config: RuntimeConfig,
255    durable_executor_queue: Option<Arc<dyn turul_a2a::durable_executor::DurableExecutorQueue>>,
256    path_prefix: Option<String>,
257    outbound_url_validator: Option<turul_a2a::push::ssrf::OutboundUrlValidator>,
258}
259
260impl LambdaA2aServerBuilder {
261    pub fn new() -> Self {
262        Self {
263            executor: None,
264            task_storage: None,
265            push_storage: None,
266            event_store: None,
267            atomic_store: None,
268            cancellation_supervisor: None,
269            push_delivery_store: None,
270            middleware: vec![],
271            runtime_config: RuntimeConfig::default(),
272            durable_executor_queue: None,
273            path_prefix: None,
274            outbound_url_validator: None,
275        }
276    }
277
278    /// Configure an HTTP path prefix to strip before the A2A router
279    /// sees the request. Needed when Lambda sits behind an API Gateway
280    /// whose stage + resource tree is forwarded into the function
281    /// (e.g. REST API `AWS_PROXY` integrations surface
282    /// `/stage/agent/message:send` where the A2A
283    /// router expects `/message:send`).
284    ///
285    /// The prefix must start with `/` and must not end with `/`
286    /// (unless it is exactly `/`, which is a no-op). Segment boundaries
287    /// are respected — a prefix of `/dev` will not strip `/devs/...`.
288    /// Paths that do not start with the prefix pass through unchanged
289    /// (the router will 404 on a genuinely unknown path, which is the
290    /// correct failure mode). Applies to both
291    /// [`LambdaA2aHandler::run_http_only`] and
292    /// `LambdaA2aHandler::run_http_and_sqs`. SQS events are
293    /// unaffected.
294    pub fn strip_path_prefix(mut self, prefix: impl Into<String>) -> Self {
295        self.path_prefix = Some(prefix.into());
296        self
297    }
298
299    /// Wire a durable executor queue. When set, the
300    /// `return_immediately = true` path in `core_send_message` enqueues
301    /// a [`turul_a2a::durable_executor::QueuedExecutorJob`] on this
302    /// queue instead of spawning the executor locally, and the
303    /// capability flag `RuntimeConfig::supports_return_immediately` is
304    /// turned **back on** as an implementation detail — the capability
305    /// cannot be claimed without supplying the queue.
306    ///
307    /// Adopters should typically reach for the SQS-specific helper
308    /// `Self::with_sqs_return_immediately` instead; this generic
309    /// method exists so adopters can inject in-memory fakes for tests
310    /// or provide non-SQS transports (Kinesis, Step Functions task
311    /// token, self-invoke, etc.).
312    pub fn with_durable_executor(
313        mut self,
314        queue: Arc<dyn turul_a2a::durable_executor::DurableExecutorQueue>,
315    ) -> Self {
316        self.durable_executor_queue = Some(queue);
317        self.runtime_config.supports_return_immediately = true;
318        self
319    }
320
321    /// Wire the AWS SQS durable executor queue. Ergonomic
322    /// helper over [`Self::with_durable_executor`] that constructs a
323    /// [`SqsDurableExecutorQueue`] from a `queue_url` + shared SQS
324    /// client. Requires the `sqs` feature.
325    #[cfg(feature = "sqs")]
326    pub fn with_sqs_return_immediately(
327        self,
328        queue_url: impl Into<String>,
329        sqs_client: Arc<aws_sdk_sqs::Client>,
330    ) -> Self {
331        let queue = Arc::new(SqsDurableExecutorQueue::new(queue_url, sqs_client));
332        self.with_durable_executor(queue)
333    }
334
335    pub fn executor(mut self, exec: impl AgentExecutor + 'static) -> Self {
336        self.executor = Some(Arc::new(exec));
337        self
338    }
339
340    /// Set all storage from a single backend instance.
341    ///
342    /// This is the **preferred** method — a single struct implementing all
343    /// storage traits guarantees the same-backend requirement
344    /// AND ensures the cancellation supervisor reads the same backend
345    /// the cancel marker is written to. A mismatch here (e.g., DynamoDB
346    /// task storage + in-memory cancellation supervisor) silently
347    /// breaks cross-instance cancellation.
348    ///
349    /// errata: `.storage()` wires storage traits only. It does
350    /// **not** auto-register the storage as a push-delivery store, even if
351    /// the backend happens to implement [`A2aPushDeliveryStore`]. To opt in
352    /// to push delivery, call [`Self::push_delivery_store`] explicitly and
353    /// call `with_push_dispatch_enabled(true)` on the storage instance
354    /// before passing it here. Non-push deployments need neither.
355    pub fn storage<S>(mut self, storage: S) -> Self
356    where
357        S: A2aTaskStorage
358            + A2aPushNotificationStorage
359            + A2aEventStore
360            + A2aAtomicStore
361            + A2aCancellationSupervisor
362            + Clone
363            + 'static,
364    {
365        self.task_storage = Some(Arc::new(storage.clone()));
366        self.push_storage = Some(Arc::new(storage.clone()));
367        self.event_store = Some(Arc::new(storage.clone()));
368        self.atomic_store = Some(Arc::new(storage.clone()));
369        self.cancellation_supervisor = Some(Arc::new(storage));
370        self
371    }
372
373    /// Set task storage individually. Must be paired with event_store/atomic_store
374    /// AND `cancellation_supervisor()` for same-backend compliance.
375    pub fn task_storage(mut self, s: impl A2aTaskStorage + 'static) -> Self {
376        self.task_storage = Some(Arc::new(s));
377        self
378    }
379
380    /// Set push notification storage individually.
381    pub fn push_storage(mut self, s: impl A2aPushNotificationStorage + 'static) -> Self {
382        self.push_storage = Some(Arc::new(s));
383        self
384    }
385
386    /// Set event store individually. Must match task_storage backend.
387    pub fn event_store(mut self, s: impl A2aEventStore + 'static) -> Self {
388        self.event_store = Some(Arc::new(s));
389        self
390    }
391
392    /// Set atomic store individually. Must match task_storage backend.
393    pub fn atomic_store(mut self, s: impl A2aAtomicStore + 'static) -> Self {
394        self.atomic_store = Some(Arc::new(s));
395        self
396    }
397
398    /// Set the cancellation supervisor individually. Must match
399    /// task_storage backend — otherwise `:cancel` writes the marker to
400    /// one backend and the supervisor reads from another, silently
401    /// breaking cross-instance cancellation. Prefer `.storage()` for
402    /// unified wiring. Consumed by `core_cancel_task`.
403    pub fn cancellation_supervisor(mut self, s: impl A2aCancellationSupervisor + 'static) -> Self {
404        self.cancellation_supervisor = Some(Arc::new(s));
405        self
406    }
407
408    /// Set the push delivery store individually.
409    ///
410    /// Prefer `.storage()` for unified wiring. The delivery store MUST
411    /// be on the same backend as `.task_storage(...)` — the builder
412    /// rejects mismatches. Passing a delivery store also requires the
413    /// atomic store to have opted in via `with_push_dispatch_enabled(true)`
414    ///.
415    pub fn push_delivery_store(mut self, store: impl A2aPushDeliveryStore + 'static) -> Self {
416        self.push_delivery_store = Some(Arc::new(store));
417        self
418    }
419
420    /// Override the runtime configuration (timeouts, retry budgets,
421    /// push tuning). Defaults to [`RuntimeConfig::default()`]. The
422    /// builder validates push settings (claim expiry vs retry horizon,
423    /// ) when `.push_delivery_store(...)` is wired.
424    pub fn runtime_config(mut self, cfg: RuntimeConfig) -> Self {
425        self.runtime_config = cfg;
426        self
427    }
428
429    pub fn middleware(mut self, mw: Arc<dyn A2aMiddleware>) -> Self {
430        self.middleware.push(mw);
431        self
432    }
433
434    /// Operator allowlist for push webhook URLs.
435    ///
436    /// Called with each webhook URL on every delivery attempt, before
437    /// DNS resolution. Returning `Err` refuses the delivery: nothing is
438    /// resolved or POSTed, and the delivery is recorded as a failed
439    /// delivery with error class `SSRFBlocked`. The reason string is
440    /// not persisted. Runs in addition to the built-in scheme and
441    /// private-address checks, not instead of them. Takes effect only
442    /// when push delivery is wired (`push_delivery_store`).
443    pub fn outbound_url_validator(
444        mut self,
445        validator: turul_a2a::push::ssrf::OutboundUrlValidator,
446    ) -> Self {
447        self.outbound_url_validator = Some(validator);
448        self
449    }
450
451    pub fn build(self) -> Result<LambdaA2aHandler, A2aError> {
452        let executor = self
453            .executor
454            .ok_or(A2aError::Internal("executor is required".into()))?;
455        let task_storage = self.task_storage.ok_or(A2aError::Internal(
456            "task_storage is required for Lambda".into(),
457        ))?;
458        let push_storage = self.push_storage.ok_or(A2aError::Internal(
459            "push_storage is required for Lambda".into(),
460        ))?;
461        let event_store: Arc<dyn A2aEventStore> = self.event_store.ok_or(A2aError::Internal(
462            "event_store is required for Lambda (use .storage() for unified backend)".into(),
463        ))?;
464        let atomic_store: Arc<dyn A2aAtomicStore> = self.atomic_store.ok_or(A2aError::Internal(
465            "atomic_store is required for Lambda (use .storage() for unified backend)".into(),
466        ))?;
467        // the cancellation supervisor MUST be supplied. No
468        // InMemoryA2aStorage fallback: that would silently break
469        // cross-instance cancellation by reading markers from a
470        // different backend than they were written to.
471        let cancellation_supervisor: Arc<dyn A2aCancellationSupervisor> =
472            self.cancellation_supervisor.ok_or(A2aError::Internal(
473                "cancellation_supervisor is required for Lambda. Use .storage() for unified \
474                 backend wiring, or .cancellation_supervisor(...) alongside the individual \
475                 storage setters. Omitting it silently breaks cross-instance cancellation."
476                    .into(),
477            ))?;
478
479        // Same-backend enforcement: all storage traits + the
480        // cancellation supervisor must report the same `backend_name`.
481        let task_backend = task_storage.backend_name();
482        let push_backend = push_storage.backend_name();
483        let event_backend = event_store.backend_name();
484        let atomic_backend = atomic_store.backend_name();
485        let supervisor_backend = cancellation_supervisor.backend_name();
486        if task_backend != push_backend
487            || task_backend != event_backend
488            || task_backend != atomic_backend
489            || task_backend != supervisor_backend
490        {
491            return Err(A2aError::Internal(format!(
492                "Storage backend mismatch: task={task_backend}, push={push_backend}, \
493                 event={event_backend}, atomic={atomic_backend}, \
494                 cancellation_supervisor={supervisor_backend}. \
495                 All storage traits, the cancellation supervisor included, \
496                 must share one backend so cross-instance cancel markers \
497                 are observable. Use .storage() for unified backend."
498            )));
499        }
500
501        let push_delivery_store = self.push_delivery_store;
502
503        // Push-dispatch consistency: the atomic store's
504        // opt-in flag and the presence of `push_delivery_store` MUST
505        // agree. Both-true = push fully wired; both-false = non-push
506        // deployment. Mixed cases are build errors — this mirrors the
507        // main server builder (server/mod.rs).
508        match (
509            push_delivery_store.is_some(),
510            atomic_store.push_dispatch_enabled(),
511        ) {
512            (true, true) | (false, false) => {}
513            (true, false) => {
514                return Err(A2aError::Internal(
515                    "push_delivery_store wired but atomic_store.push_dispatch_enabled() \
516                     is false. Call .with_push_dispatch_enabled(true) on the backend \
517                     storage before passing it to .storage()."
518                        .into(),
519                ));
520            }
521            (false, true) => {
522                return Err(A2aError::Internal(
523                    "atomic_store.push_dispatch_enabled() is true but no \
524                     push_delivery_store is wired. Pending-dispatch markers would be \
525                     written with no consumer, imposing load-bearing infra for no \
526                     benefit. If you need to populate markers for an external \
527                     consumer, open an issue for a distinctly-named opt-in — for now, \
528                     this configuration is rejected."
529                        .into(),
530                ));
531            }
532        }
533
534        // The Lambda execution environment can freeze once the HTTP
535        // response is flushed, so a `tokio::spawn`'d executor
536        // continuation is opportunistic only. Refuse
537        // `SendMessageConfiguration.return_immediately = true` at the
538        // `core_send_message` entry point (UnsupportedOperationError —
539        // an aspect of SendMessage this runtime cannot honour, A2A
540        // v1.0.1 §3.3.2) via this capability flag instead of silently
541        // orphaning the executor.
542        //
543        // if a durable executor queue is wired via
544        // [`Self::with_durable_executor`] (or the SQS helper), the
545        // capability is re-enabled because enqueueing is durable
546        // across the Lambda freeze. The explicit re-enable lives in
547        // the builder method so the capability cannot be claimed
548        // without the mechanism.
549        let mut runtime_config = self.runtime_config;
550        if self.durable_executor_queue.is_none() {
551            runtime_config.supports_return_immediately = false;
552        }
553        // No background loops run on Lambda, so a lease written here
554        // would never be renewed and peers would fail the live task.
555        runtime_config.executor_lease_ttl = None;
556        runtime_config.executor_stall_timeout = None;
557
558        // Push dispatcher wiring. When
559        // `push_delivery_store` is present, validate its backend matches,
560        // validate the retry-horizon invariant, and construct the
561        // `PushDispatcher`. Under Lambda the dispatcher runs
562        // opportunistically post-return; correctness
563        // comes from the atomic marker + stream/scheduler recovery.
564        let push_dispatcher: Option<Arc<turul_a2a::push::PushDispatcher>> = if let Some(delivery) =
565            push_delivery_store.as_ref()
566        {
567            let push_delivery_backend = delivery.backend_name();
568            if task_backend != push_delivery_backend {
569                return Err(A2aError::Internal(format!(
570                    "Storage backend mismatch: task={task_backend}, \
571                         push_delivery={push_delivery_backend}. \
572                         All storage traits must share the same backend."
573                )));
574            }
575
576            // claim expiry must exceed the worst-case
577            // retry horizon so a live claim is not mis-classified as
578            // stale mid-retry.
579            let retry_horizon = runtime_config
580                .push_backoff_cap
581                .saturating_mul(runtime_config.push_max_attempts as u32);
582            if runtime_config.push_claim_expiry <= retry_horizon {
583                return Err(A2aError::Internal(format!(
584                    "push_claim_expiry ({:?}) must be greater than retry horizon \
585                         (push_max_attempts={} * push_backoff_cap={:?} = {:?}). \
586                         Raise push_claim_expiry or lower push_max_attempts/push_backoff_cap.",
587                    runtime_config.push_claim_expiry,
588                    runtime_config.push_max_attempts,
589                    runtime_config.push_backoff_cap,
590                    retry_horizon
591                )));
592            }
593
594            let delivery_cfg =
595                turul_a2a::push::delivery::PushDeliveryConfig::from_runtime_config(&runtime_config);
596
597            let instance_id = format!("a2a-lambda-{}", uuid::Uuid::now_v7());
598            let worker = turul_a2a::push::delivery::PushDeliveryWorker::new(
599                delivery.clone(),
600                delivery_cfg,
601                self.outbound_url_validator.clone(),
602                instance_id,
603            )
604            .map_err(|e| A2aError::Internal(format!("push worker build failed: {e}")))?;
605
606            Some(Arc::new(turul_a2a::push::PushDispatcher::new(
607                Arc::new(worker),
608                push_storage.clone(),
609                task_storage.clone(),
610            )))
611        } else {
612            None
613        };
614
615        let state = AppState {
616            executor,
617            task_storage,
618            push_storage,
619            event_store,
620            atomic_store,
621            event_broker: TaskEventBroker::new(),
622            middleware_stack: Arc::new(MiddlewareStack::new(self.middleware)),
623            runtime_config,
624            in_flight: Arc::new(turul_a2a::server::in_flight::InFlightRegistry::new()),
625            cancellation_supervisor,
626            push_delivery_store,
627            push_dispatcher,
628            durable_executor_queue: self.durable_executor_queue,
629        };
630
631        let router = build_router(state.clone());
632
633        let path_prefix = match self.path_prefix {
634            None => None,
635            Some(p) if p.is_empty() || p == "/" => None,
636            Some(p) => {
637                if !p.starts_with('/') {
638                    return Err(A2aError::InvalidRequest {
639                        message: format!(
640                            "LambdaA2aServerBuilder::strip_path_prefix: prefix must start with '/'; got {p:?}"
641                        ),
642                    });
643                }
644                if p.ends_with('/') {
645                    return Err(A2aError::InvalidRequest {
646                        message: format!(
647                            "LambdaA2aServerBuilder::strip_path_prefix: prefix must not end with '/' (except '/'); got {p:?}"
648                        ),
649                    });
650                }
651                Some(Arc::from(p))
652            }
653        };
654
655        Ok(LambdaA2aHandler {
656            router,
657            state,
658            path_prefix,
659        })
660    }
661}
662
663impl Default for LambdaA2aServerBuilder {
664    fn default() -> Self {
665        Self::new()
666    }
667}
668
669/// Lambda handler wrapping the axum Router.
670#[derive(Clone)]
671pub struct LambdaA2aHandler {
672    router: axum::Router,
673    /// Held for SQS-event dispatch (`handle_sqs`). Always
674    /// populated — the SQS handler method is gated behind the `sqs`
675    /// feature but the state is carried unconditionally so callers can
676    /// reach it for diagnostics or future non-HTTP / non-SQS handlers.
677    #[cfg_attr(not(feature = "sqs"), allow(dead_code))]
678    state: AppState,
679    /// Leading path segment to strip from each incoming HTTP request
680    /// before the A2A router sees it. `None` = no stripping. Set via
681    /// [`LambdaA2aServerBuilder::strip_path_prefix`].
682    path_prefix: Option<Arc<str>>,
683}
684
685impl LambdaA2aHandler {
686    pub fn builder() -> LambdaA2aServerBuilder {
687        LambdaA2aServerBuilder::new()
688    }
689
690    /// Handle a Lambda HTTP request.
691    pub async fn handle(
692        &self,
693        event: lambda_http::Request,
694    ) -> Result<lambda_http::Response<lambda_http::Body>, lambda_http::Error> {
695        let axum_req = lambda_to_axum_request(event)?;
696        let axum_req = match &self.path_prefix {
697            Some(prefix) => adapter::strip_request_path_prefix(axum_req, prefix),
698            None => axum_req,
699        };
700
701        let axum_resp = tower::ServiceExt::oneshot(self.router.clone(), axum_req)
702            .await
703            .map_err(|e| lambda_http::Error::from(format!("Router error: {e}")))?;
704
705        axum_to_lambda_response(axum_resp).await
706    }
707
708    /// Handle a raw Lambda HTTP event delivered as `serde_json::Value`
709    /// and return a response envelope **matching the inbound event
710    /// shape**:
711    ///
712    /// | Inbound shape detected by `lambda_http::LambdaRequest`   | Response envelope                  |
713    /// |----------------------------------------------------------|------------------------------------|
714    /// | API Gateway v1 (REST) — top-level `httpMethod` + `path`  | `ApiGatewayProxyResponse`          |
715    /// | API Gateway v2 / Function URL — `requestContext.http`    | `ApiGatewayV2httpResponse`         |
716    /// | ALB target group — `requestContext.elb`                  | `AlbTargetGroupResponse`           |
717    /// | WebSocket / unrecognised                                  | `Err(...)`                         |
718    ///
719    /// Mismatching envelopes — most commonly a v2 response sent in
720    /// response to a v1 invocation — cause API Gateway to return
721    /// `502 Bad Gateway` regardless of the body, because the response
722    /// fails the gateway's contract validation. This method ensures
723    /// symmetry: response shape is always picked from the inbound
724    /// shape, never hardcoded.
725    ///
726    /// This is the framework's canonical HTTP envelope conversion
727    /// primitive — useful when a single Lambda function routes among
728    /// HTTP and one or more non-framework triggers (EventBridge,
729    /// DynamoDB streams, custom invoke) and the adopter drives
730    /// [`lambda_runtime::run`] with `serde_json::Value` directly.
731    /// Handles the `LambdaRequest` deserialization, the text-vs-base64
732    /// content-type detection, and the response envelope rebuild.
733    ///
734    /// Body encoding: text for `text/*`, `application/json`,
735    /// `application/xml`, `application/javascript`, any `+json` / `+xml`
736    /// structured-syntax suffix (RFC 6839, e.g. `application/a2a+json`),
737    /// or anything with `charset=`; base64 otherwise.
738    ///
739    /// Path-prefix stripping (see
740    /// [`LambdaA2aServerBuilder::strip_path_prefix`]) is applied
741    /// because dispatch flows through [`Self::handle`]. Adopters who
742    /// only serve HTTP should continue to use [`Self::run_http_only`]
743    /// or [`LambdaA2aServerBuilder::run`]; this entry point exists for
744    /// composition with adopter-owned third triggers.
745    pub async fn handle_http_event_value(
746        &self,
747        value: serde_json::Value,
748    ) -> Result<serde_json::Value, lambda_runtime::Error> {
749        use base64::Engine;
750        use http_body_util::BodyExt;
751
752        let lambda_req: lambda_http::request::LambdaRequest = serde_json::from_value(value)
753            .map_err(|e| lambda_runtime::Error::from(format!("invalid HTTP event: {e}")))?;
754
755        // Capture the inbound shape so we can emit the matching
756        // response envelope. Lost once we convert to `lambda_http::Request`.
757        let shape = match &lambda_req {
758            lambda_http::request::LambdaRequest::ApiGatewayV1(_) => HttpEventShape::ApiGatewayV1,
759            lambda_http::request::LambdaRequest::ApiGatewayV2(_) => HttpEventShape::ApiGatewayV2,
760            lambda_http::request::LambdaRequest::Alb(_) => HttpEventShape::Alb,
761            lambda_http::request::LambdaRequest::WebSocket(_) => {
762                return Err(lambda_runtime::Error::from(
763                    "WebSocket Lambda events are not supported by this adapter",
764                ));
765            }
766            _ => {
767                return Err(lambda_runtime::Error::from(
768                    "unsupported Lambda HTTP event variant",
769                ));
770            }
771        };
772
773        let req: lambda_http::Request = lambda_req.into();
774
775        let resp = self
776            .handle(req)
777            .await
778            .map_err(|e| lambda_runtime::Error::from(format!("handler error: {e}")))?;
779        let (parts, body) = resp.into_parts();
780
781        let bytes = body
782            .collect()
783            .await
784            .map_err(|e| lambda_runtime::Error::from(format!("body collect: {e}")))?
785            .to_bytes();
786
787        let ct = parts
788            .headers
789            .get(http::header::CONTENT_TYPE)
790            .and_then(|v| v.to_str().ok())
791            .unwrap_or("application/octet-stream")
792            .to_ascii_lowercase();
793        let essence = ct.split(';').next().unwrap_or("").trim();
794        let is_text = essence.starts_with("text/")
795            || matches!(
796                essence,
797                "application/json" | "application/xml" | "application/javascript"
798            )
799            || essence.ends_with("+json")
800            || essence.ends_with("+xml")
801            || ct.contains("charset=");
802
803        let (body_str, is_base64) = if bytes.is_empty() {
804            (None, false)
805        } else if is_text {
806            match std::str::from_utf8(&bytes) {
807                Ok(s) => (Some(s.to_string()), false),
808                Err(_) => (
809                    Some(base64::engine::general_purpose::STANDARD.encode(&bytes)),
810                    true,
811                ),
812            }
813        } else {
814            (
815                Some(base64::engine::general_purpose::STANDARD.encode(&bytes)),
816                true,
817            )
818        };
819
820        let status = parts.status.as_u16() as i64;
821        let headers = parts.headers;
822        let body_lambda = body_str.map(aws_lambda_events::encodings::Body::Text);
823
824        match shape {
825            HttpEventShape::ApiGatewayV1 => {
826                let mut api_resp = aws_lambda_events::apigw::ApiGatewayProxyResponse::default();
827                api_resp.status_code = status;
828                api_resp.headers = headers;
829                api_resp.body = body_lambda;
830                api_resp.is_base64_encoded = is_base64;
831                serde_json::to_value(api_resp).map_err(|e| {
832                    lambda_runtime::Error::from(format!("serialise APIGW v1 response: {e}"))
833                })
834            }
835            HttpEventShape::ApiGatewayV2 => {
836                let mut api_resp = aws_lambda_events::apigw::ApiGatewayV2httpResponse::default();
837                api_resp.status_code = status;
838                api_resp.headers = headers;
839                api_resp.body = body_lambda;
840                api_resp.is_base64_encoded = is_base64;
841                serde_json::to_value(api_resp).map_err(|e| {
842                    lambda_runtime::Error::from(format!("serialise APIGW v2 response: {e}"))
843                })
844            }
845            HttpEventShape::Alb => {
846                let mut api_resp = aws_lambda_events::alb::AlbTargetGroupResponse::default();
847                api_resp.status_code = status;
848                api_resp.headers = headers;
849                api_resp.body = body_lambda;
850                api_resp.is_base64_encoded = is_base64;
851                serde_json::to_value(api_resp).map_err(|e| {
852                    lambda_runtime::Error::from(format!("serialise ALB response: {e}"))
853                })
854            }
855        }
856    }
857}
858
859/// Inbound HTTP event shape detected by [`LambdaA2aHandler::handle_http_event_value`].
860///
861/// The response envelope must match the inbound shape — see method
862/// docs for the contract.
863#[derive(Debug, Clone, Copy, PartialEq, Eq)]
864enum HttpEventShape {
865    ApiGatewayV1,
866    ApiGatewayV2,
867    Alb,
868}
869
870impl LambdaA2aHandler {
871    /// Handle an SQS batch from the durable executor queue.
872    ///
873    /// Returns `SqsBatchResponse` whose `batch_item_failures` list
874    /// tells the event source mapping which records to retry.
875    /// Requires `FunctionResponseTypes: ["ReportBatchItemFailures"]`
876    /// on the mapping.
877    ///
878    /// Each record: deserialize envelope → load task → terminal-no-op
879    /// check → cancel-marker direct-CANCELED commit → run executor.
880    /// See `durable::drive_sqs_batch` for the full semantics.
881    #[cfg(feature = "sqs")]
882    pub async fn handle_sqs(
883        &self,
884        event: aws_lambda_events::event::sqs::SqsEvent,
885    ) -> aws_lambda_events::event::sqs::SqsBatchResponse {
886        durable::drive_sqs_batch(&self.state, event).await
887    }
888
889    /// Run this handler as a pure HTTP Lambda. Strict: any non-HTTP
890    /// event shape (SQS, DynamoDB stream, scheduler, …) causes
891    /// `lambda_http::run` to return a deserialization error on
892    /// invocation.
893    ///
894    /// Appropriate for Lambdas whose only trigger is a Function URL,
895    /// API Gateway, or ALB. The request Lambda in the two-Lambda
896    /// durable-executor topology is the canonical caller.
897    pub async fn run_http_only(self) -> Result<(), lambda_runtime::Error> {
898        let handler = Arc::new(self);
899        lambda_http::run(lambda_http::service_fn(move |event| {
900            let h = Arc::clone(&handler);
901            async move { h.handle(event).await }
902        }))
903        .await
904    }
905}
906
907// Default entrypoint when the `sqs` feature is off — `.run()` aliases
908// the HTTP-only runner. With the `sqs` feature enabled, `.run()` lives
909// in `durable.rs` and routes HTTP+SQS via the classifier.
910#[cfg(not(feature = "sqs"))]
911impl LambdaA2aHandler {
912    /// Default Lambda runner. Dispatches based on the event shape the
913    /// binary receives. Without the `sqs` feature this is HTTP-only
914    /// (equivalent to [`LambdaA2aHandler::run_http_only`]).
915    pub async fn run(self) -> Result<(), lambda_runtime::Error> {
916        self.run_http_only().await
917    }
918}
919
920impl LambdaA2aServerBuilder {
921    /// Build and run in one call. Sugar for
922    /// `self.build()?.run().await`. Hides the handler from the adopter —
923    /// use `.build()` + `handler.run_*()` instead when the handler is
924    /// needed for unit tests, custom middleware, or an explicit
925    /// topology runner.
926    pub async fn run(self) -> Result<(), lambda_runtime::Error> {
927        let handler = self.build().map_err(|e| {
928            lambda_runtime::Error::from(format!("LambdaA2aServerBuilder::run: {e}"))
929        })?;
930        handler.run().await
931    }
932}
933
934#[cfg(test)]
935mod handle_http_event_value_shape_tests {
936    //! Regression tests for the event-shape symmetry contract on
937    //! [`LambdaA2aHandler::handle_http_event_value`]. The wire-shape
938    //! mismatch (v2 response to a v1 invocation) is what causes API
939    //! Gateway REST integrations to return `502 Bad Gateway`. These
940    //! tests pin the contract: response envelope must always match
941    //! the inbound request envelope.
942
943    use super::*;
944    use std::sync::Arc;
945    use turul_a2a::executor::{AgentExecutor, ExecutionContext};
946    use turul_a2a::server::RuntimeConfig;
947    use turul_a2a::server::in_flight::InFlightRegistry;
948    use turul_a2a::storage::InMemoryA2aStorage;
949    use turul_a2a::streaming::TaskEventBroker;
950    use turul_a2a_types::{Message, Task};
951
952    struct NoOpExecutor;
953
954    #[async_trait::async_trait]
955    impl AgentExecutor for NoOpExecutor {
956        async fn execute(
957            &self,
958            task: &mut Task,
959            _msg: &Message,
960            _ctx: &ExecutionContext,
961        ) -> Result<(), turul_a2a::error::A2aError> {
962            let mut proto = task.as_proto().clone();
963            proto.status = Some(turul_a2a_proto::TaskStatus {
964                state: turul_a2a_proto::TaskState::Completed.into(),
965                message: None,
966                timestamp: None,
967            });
968            *task = Task::try_from(proto).unwrap();
969            Ok(())
970        }
971        fn agent_card(&self) -> turul_a2a_proto::AgentCard {
972            turul_a2a_proto::AgentCard {
973                name: "test-agent".to_string(),
974                ..Default::default()
975            }
976        }
977    }
978
979    fn build_handler() -> LambdaA2aHandler {
980        let s = Arc::new(InMemoryA2aStorage::new());
981        let state = AppState {
982            executor: Arc::new(NoOpExecutor),
983            task_storage: s.clone(),
984            push_storage: s.clone(),
985            event_store: s.clone(),
986            atomic_store: s.clone(),
987            event_broker: TaskEventBroker::new(),
988            middleware_stack: Arc::new(MiddlewareStack::new(vec![])),
989            runtime_config: RuntimeConfig::default(),
990            in_flight: Arc::new(InFlightRegistry::new()),
991            cancellation_supervisor: s.clone(),
992            push_delivery_store: None,
993            push_dispatcher: None,
994            durable_executor_queue: None,
995        };
996        let router = build_router(state.clone());
997        LambdaA2aHandler {
998            router,
999            state,
1000            path_prefix: None,
1001        }
1002    }
1003
1004    /// API Gateway v2 / Function URL event. `requestContext.http.method`
1005    /// is what `LambdaRequest::ApiGatewayV2` keys off.
1006    fn apigw_v2_agent_card_get() -> serde_json::Value {
1007        serde_json::json!({
1008            "version": "2.0",
1009            "routeKey": "GET /.well-known/agent-card.json",
1010            "rawPath": "/.well-known/agent-card.json",
1011            "rawQueryString": "",
1012            "headers": {"accept": "application/json", "a2a-version": "1.0"},
1013            "requestContext": {
1014                "accountId": "000000000000",
1015                "apiId": "api",
1016                "domainName": "fake.execute-api",
1017                "domainPrefix": "fake",
1018                "http": {
1019                    "method": "GET",
1020                    "path": "/.well-known/agent-card.json",
1021                    "protocol": "HTTP/1.1",
1022                    "sourceIp": "127.0.0.1",
1023                    "userAgent": "test"
1024                },
1025                "requestId": "rid",
1026                "routeKey": "GET /.well-known/agent-card.json",
1027                "stage": "$default",
1028                "time": "01/Jan/2026:00:00:00 +0000",
1029                "timeEpoch": 1_735_689_600_000i64
1030            },
1031            "isBase64Encoded": false
1032        })
1033    }
1034
1035    /// API Gateway v1 (REST) event. Top-level `httpMethod` + `path` and
1036    /// `requestContext.identity` (no `requestContext.http`) is what
1037    /// `LambdaRequest::ApiGatewayV1` keys off. This is the shape REST
1038    /// API + AWS_PROXY integrations send into the Lambda — the live
1039    /// fleet topology that 502'd against a hardcoded v2 response.
1040    ///
1041    /// `stage` is `$default` here so `lambda_http` does not prepend a
1042    /// stage prefix — this fixture exists to pin the response-shape
1043    /// contract, not the path-rewriting behaviour. See the
1044    /// `strip_path_prefix_*` tests for stage-prefix coverage.
1045    fn apigw_v1_agent_card_get_with_stage(stage: &str, path: &str) -> serde_json::Value {
1046        let request_context_path = if stage == "$default" {
1047            path.to_string()
1048        } else {
1049            format!("/{stage}{path}")
1050        };
1051        serde_json::json!({
1052            "resource": "/{proxy+}",
1053            "path": path,
1054            "httpMethod": "GET",
1055            "headers": {"accept": "application/json", "a2a-version": "1.0"},
1056            "multiValueHeaders": {},
1057            "queryStringParameters": null,
1058            "multiValueQueryStringParameters": null,
1059            "pathParameters": {"proxy": path.trim_start_matches('/').to_string()},
1060            "stageVariables": null,
1061            "requestContext": {
1062                "accountId": "000000000000",
1063                "apiId": "abc123",
1064                "domainName": "fake.execute-api",
1065                "domainPrefix": "fake",
1066                "extendedRequestId": "xrid",
1067                "httpMethod": "GET",
1068                "identity": {
1069                    "sourceIp": "127.0.0.1",
1070                    "userAgent": "test"
1071                },
1072                "path": request_context_path,
1073                "protocol": "HTTP/1.1",
1074                "requestId": "rid",
1075                "requestTime": "01/Jan/2026:00:00:00 +0000",
1076                "requestTimeEpoch": 1_735_689_600_000i64,
1077                "resourceId": "rsrc",
1078                "resourcePath": "/{proxy+}",
1079                "stage": stage
1080            },
1081            "body": null,
1082            "isBase64Encoded": false
1083        })
1084    }
1085
1086    fn apigw_v1_agent_card_get() -> serde_json::Value {
1087        apigw_v1_agent_card_get_with_stage("$default", "/.well-known/agent-card.json")
1088    }
1089
1090    /// ALB target-group event. `requestContext.elb` is what
1091    /// `LambdaRequest::Alb` keys off.
1092    fn alb_agent_card_get() -> serde_json::Value {
1093        serde_json::json!({
1094            "requestContext": {
1095                "elb": {
1096                    "targetGroupArn": "arn:aws:elasticloadbalancing:ap-southeast-2:000:targetgroup/fake/00"
1097                }
1098            },
1099            "httpMethod": "GET",
1100            "path": "/.well-known/agent-card.json",
1101            "queryStringParameters": {},
1102            "headers": {"accept": "application/json", "a2a-version": "1.0"},
1103            "body": "",
1104            "isBase64Encoded": false
1105        })
1106    }
1107
1108    /// REST v1 inbound MUST produce a v1 response shape. This is the
1109    /// regression for the live-fleet 502.
1110    #[tokio::test]
1111    async fn v1_inbound_event_emits_v1_response_envelope() {
1112        let handler = build_handler();
1113        let resp_value = handler
1114            .handle_http_event_value(apigw_v1_agent_card_get())
1115            .await
1116            .expect("handle_http_event_value(v1) must succeed");
1117
1118        // Parses cleanly as v1.
1119        let v1: aws_lambda_events::apigw::ApiGatewayProxyResponse =
1120            serde_json::from_value(resp_value.clone())
1121                .expect("response must deserialize as ApiGatewayProxyResponse (v1)");
1122        assert_eq!(v1.status_code, 200);
1123        match v1.body {
1124            Some(aws_lambda_events::encodings::Body::Text(s)) => {
1125                assert!(s.contains("\"name\""), "v1 body shape: {s}");
1126            }
1127            other => panic!("expected text body, got {other:?}"),
1128        }
1129
1130        // Negative: v1 response JSON must NOT contain the v2-only
1131        // `cookies` field. Presence of `cookies` is API Gateway REST's
1132        // 502 trigger.
1133        assert!(
1134            resp_value.get("cookies").is_none(),
1135            "v1 response must not carry v2-only `cookies` field; payload: {resp_value}"
1136        );
1137    }
1138
1139    /// API Gateway v2 / Function URL inbound MUST produce a v2 response
1140    /// shape (current behaviour, locked in to prevent the inverse
1141    /// regression).
1142    #[tokio::test]
1143    async fn v2_inbound_event_emits_v2_response_envelope() {
1144        let handler = build_handler();
1145        let resp_value = handler
1146            .handle_http_event_value(apigw_v2_agent_card_get())
1147            .await
1148            .expect("handle_http_event_value(v2) must succeed");
1149
1150        let v2: aws_lambda_events::apigw::ApiGatewayV2httpResponse =
1151            serde_json::from_value(resp_value.clone())
1152                .expect("response must deserialize as ApiGatewayV2httpResponse (v2)");
1153        assert_eq!(v2.status_code, 200);
1154        match v2.body {
1155            Some(aws_lambda_events::encodings::Body::Text(s)) => {
1156                assert!(s.contains("\"name\""), "v2 body shape: {s}");
1157            }
1158            other => panic!("expected text body, got {other:?}"),
1159        }
1160    }
1161
1162    /// ALB inbound MUST produce an ALB response shape.
1163    #[tokio::test]
1164    async fn alb_inbound_event_emits_alb_response_envelope() {
1165        let handler = build_handler();
1166        let resp_value = handler
1167            .handle_http_event_value(alb_agent_card_get())
1168            .await
1169            .expect("handle_http_event_value(alb) must succeed");
1170
1171        let alb: aws_lambda_events::alb::AlbTargetGroupResponse =
1172            serde_json::from_value(resp_value)
1173                .expect("response must deserialize as AlbTargetGroupResponse");
1174        assert_eq!(alb.status_code, 200);
1175    }
1176
1177    /// Live-fleet end-to-end regression: REST v1 stage-prefixed event
1178    /// + `strip_path_prefix("/dev")` on the handler routes through
1179    /// the agent-card endpoint AND emits a v1 response envelope. This
1180    /// is the exact scenario the live fleet was 502'ing on.
1181    #[tokio::test]
1182    async fn v1_stage_prefix_inbound_with_strip_emits_v1_response() {
1183        let s = Arc::new(InMemoryA2aStorage::new());
1184        let state = AppState {
1185            executor: Arc::new(NoOpExecutor),
1186            task_storage: s.clone(),
1187            push_storage: s.clone(),
1188            event_store: s.clone(),
1189            atomic_store: s.clone(),
1190            event_broker: TaskEventBroker::new(),
1191            middleware_stack: Arc::new(MiddlewareStack::new(vec![])),
1192            runtime_config: RuntimeConfig::default(),
1193            in_flight: Arc::new(InFlightRegistry::new()),
1194            cancellation_supervisor: s.clone(),
1195            push_delivery_store: None,
1196            push_dispatcher: None,
1197            durable_executor_queue: None,
1198        };
1199        let router = build_router(state.clone());
1200        let handler = LambdaA2aHandler {
1201            router,
1202            state,
1203            path_prefix: Some(Arc::from("/dev")),
1204        };
1205
1206        let event = apigw_v1_agent_card_get_with_stage("dev", "/.well-known/agent-card.json");
1207        let resp_value = handler
1208            .handle_http_event_value(event)
1209            .await
1210            .expect("v1 stage-prefixed event must succeed under strip_path_prefix");
1211
1212        let v1: aws_lambda_events::apigw::ApiGatewayProxyResponse =
1213            serde_json::from_value(resp_value)
1214                .expect("response must deserialize as ApiGatewayProxyResponse");
1215        assert_eq!(v1.status_code, 200);
1216        match v1.body {
1217            Some(aws_lambda_events::encodings::Body::Text(s)) => {
1218                assert!(s.contains("\"name\""), "v1 stage-prefix body: {s}");
1219            }
1220            other => panic!("expected text body, got {other:?}"),
1221        }
1222    }
1223
1224    /// Cross-shape decoding regression: a v1 response JSON carries
1225    /// `multiValueHeaders` (REST shape) but no `cookies`; a v2 response
1226    /// carries `cookies` but no v1-only `multiValueHeaders` semantics.
1227    /// Confirms the two shapes are wire-distinguishable, which is why
1228    /// the gateway 502s on a mismatch.
1229    #[tokio::test]
1230    async fn v1_response_does_not_match_v2_envelope_strictly() {
1231        let handler = build_handler();
1232        let v1_value = handler
1233            .handle_http_event_value(apigw_v1_agent_card_get())
1234            .await
1235            .unwrap();
1236
1237        // Sanity: v1 response JSON has no v2-only `cookies` field.
1238        assert!(v1_value.get("cookies").is_none());
1239
1240        let v2_value = handler
1241            .handle_http_event_value(apigw_v2_agent_card_get())
1242            .await
1243            .unwrap();
1244        // Sanity: v2 response JSON carries the v2 `cookies` field
1245        // (default empty array — still present on the wire).
1246        assert!(
1247            v2_value.get("cookies").is_some(),
1248            "v2 response should carry the v2-only cookies field"
1249        );
1250    }
1251}