1use crate::auth::RequestContext;
18use crate::envelope::{
19 codes, A2aHeaders, A2aMethod, JsonRpcError, JsonRpcRequest, JsonRpcResponse,
20};
21use crate::error::{A2aBuilderError, A2aError};
22use crate::handler::A2aHandler;
23use crate::types::{
24 CancelTaskParams, DeletePushNotificationConfigParams, GetExtendedAgentCardParams,
25 GetPushNotificationConfigParams, GetTaskParams, ListPushNotificationConfigsParams,
26 ListTasksParams, Message, PushNotificationConfigParams, SendMessageParams, SendMessageResult,
27 SubscribeToTaskParams, Task, TaskStatus,
28};
29use bytes::Bytes;
30use klieo_auth_common::Authenticator;
31use klieo_core::{DurableName, Headers, Msg, Pubsub};
32
33const A2A_CANCEL_SUBJECT_PREFIX: &str = "klieo.a2a.cancel.";
34const A2A_CANCEL_SUBJECT_PATTERN: &str = "klieo.a2a.cancel.>";
35const A2A_CANCEL_LOG_TARGET: &str = "a2a.cancel";
36use serde::de::DeserializeOwned;
37use serde::Serialize;
38use serde_json::Value;
39use std::pin::Pin;
40use std::sync::Arc;
41use tokio::sync::Semaphore;
42use tokio_stream::StreamExt;
43use tracing::{error, instrument, warn};
44use tracing_opentelemetry::OpenTelemetrySpanExt as _;
45
46pub(crate) const DEFAULT_PUBLISH_PERMITS: usize = 64;
52
53pub const LEADER_TTL: std::time::Duration = std::time::Duration::from_secs(5);
62
63const A2A_LEADER_KEY_PREFIX: &str = "a2a.";
66
67const A2A_OWNERSHIP_KEY_PREFIX: &str = "a2a.";
72
73fn ownership_key(task_id: &str) -> String {
74 format!("{A2A_OWNERSHIP_KEY_PREFIX}{task_id}")
75}
76
77pub type TaskEventStream = Pin<Box<dyn futures::Stream<Item = TaskEvent> + Send>>;
81
82#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
87#[non_exhaustive]
88pub struct TaskEvent {
89 pub task_id: String,
91 pub status: TaskStatus,
93 pub message: Option<Message>,
95 pub final_event: bool,
98 #[serde(default)]
103 pub event_id: u64,
104}
105
106impl TaskEvent {
107 pub fn new(
112 task_id: impl Into<String>,
113 status: TaskStatus,
114 message: Option<Message>,
115 final_event: bool,
116 ) -> Self {
117 Self {
118 task_id: task_id.into(),
119 status,
120 message,
121 final_event,
122 event_id: 0,
123 }
124 }
125
126 #[must_use]
132 pub fn with_event_id(mut self, event_id: u64) -> Self {
133 self.event_id = event_id;
134 self
135 }
136
137 #[allow(dead_code)]
143 pub(crate) fn synthetic_corrupt(task_id: &str) -> Self {
144 Self {
145 task_id: task_id.into(),
146 status: TaskStatus::Failed,
147 message: None,
148 final_event: true,
149 event_id: 0,
150 }
151 }
152}
153
154struct LeaderHoldStream<S> {
166 inner: S,
167 _leader: Option<klieo_core::LeaderHandle>,
169 _ownership: Option<klieo_core::OwnershipHandle>,
171}
172
173impl<S: futures::Stream + Unpin> futures::Stream for LeaderHoldStream<S> {
174 type Item = S::Item;
175 fn poll_next(
176 mut self: std::pin::Pin<&mut Self>,
177 cx: &mut std::task::Context<'_>,
178 ) -> std::task::Poll<Option<S::Item>> {
179 std::pin::Pin::new(&mut self.inner).poll_next(cx)
180 }
181}
182
183enum LeaderProbe {
185 NoRegistry,
187 Alive,
189 Dead,
191}
192
193#[derive(Clone)]
202pub struct TaskEventSink {
203 pubsub: Arc<dyn klieo_core::Pubsub>,
204 permits: Arc<Semaphore>,
205}
206
207impl TaskEventSink {
208 pub fn new(pubsub: Arc<dyn klieo_core::Pubsub>) -> Self {
215 Self::with_permits(pubsub, Arc::new(Semaphore::new(DEFAULT_PUBLISH_PERMITS)))
216 }
217
218 pub fn with_permits(pubsub: Arc<dyn klieo_core::Pubsub>, permits: Arc<Semaphore>) -> Self {
225 Self { pubsub, permits }
226 }
227
228 #[instrument(
257 skip_all,
258 fields(
259 messaging.system = "klieo-bus",
260 messaging.destination = tracing::field::Empty,
261 messaging.operation = "publish",
262 messaging.message.payload_size_bytes = tracing::field::Empty,
263 klieo.stream_id = %event.task_id,
264 ),
265 err,
266 )]
267 pub async fn send(&self, event: TaskEvent) -> Result<(), A2aError> {
268 klieo_core::validate_subject_token(&event.task_id)?;
269 let subject = format!("klieo.a2a.task.{}", event.task_id);
270 tracing::Span::current().record("messaging.destination", subject.as_str());
271 let bytes = serde_json::to_vec(&event).map_err(|e| A2aError::Internal {
272 source: Box::new(e),
273 })?;
274 tracing::Span::current().record("messaging.message.payload_size_bytes", bytes.len());
275
276 let mut headers = klieo_core::Headers::default();
280 let cx = tracing::Span::current().context();
281 klieo_core::inject_traceparent(&mut headers, &cx);
282
283 let permit = match self.permits.clone().try_acquire_owned() {
284 Ok(p) => p,
285 Err(_) => {
286 tracing::warn!(
287 target: "a2a.fanout",
288 subject = %subject,
289 available_permits = self.permits.available_permits(),
290 "task event publish dropped: concurrency cap reached",
291 );
292 return Ok(()); }
294 };
295 let result = self
296 .pubsub
297 .publish(&subject, bytes::Bytes::from(bytes), headers)
298 .await;
299 drop(permit);
300 result?;
301 Ok(())
302 }
303}
304
305#[derive(Default)]
321pub struct A2aDispatcherBuilder {
322 handler: Option<Arc<dyn A2aHandler>>,
323 authenticator: Option<Arc<dyn Authenticator>>,
324 pubsub: Option<Arc<dyn klieo_core::Pubsub>>,
325 publish_concurrency: Option<usize>,
326 subscribe_cancels: bool,
327 leader_kv: Option<Arc<dyn klieo_core::KvStore>>,
328 tenant_kv: Option<Arc<dyn klieo_core::KvStore>>,
329 tenant_strict: bool,
330 leader_ttl: Option<std::time::Duration>,
331 leader_heartbeat_interval: Option<std::time::Duration>,
332 max_failover_attempts: Option<u32>,
333 kv_reaper_interval: Option<std::time::Duration>,
334 profile: klieo_core::DeploymentProfile,
335}
336
337impl A2aDispatcherBuilder {
338 pub fn handler(mut self, handler: Arc<dyn A2aHandler>) -> Self {
340 self.handler = Some(handler);
341 self
342 }
343
344 pub fn authenticator(mut self, authenticator: Arc<dyn Authenticator>) -> Self {
348 self.authenticator = Some(authenticator);
349 self
350 }
351
352 pub fn profile(mut self, profile: klieo_core::DeploymentProfile) -> Self {
356 self.profile = profile;
357 self
358 }
359
360 pub fn pubsub(mut self, pubsub: Arc<dyn klieo_core::Pubsub>) -> Self {
364 self.pubsub = Some(pubsub);
365 self
366 }
367
368 #[must_use]
373 pub fn with_in_process_pubsub(mut self) -> Self {
374 self.pubsub = Some(klieo_bus_memory::MemoryBus::new().pubsub.clone());
375 self
376 }
377
378 #[must_use]
383 pub fn with_publish_concurrency(mut self, permits: usize) -> Self {
384 self.publish_concurrency = Some(permits);
385 self
386 }
387
388 #[must_use]
394 pub fn with_cancel_subscription(mut self) -> Self {
395 self.subscribe_cancels = true;
396 self
397 }
398
399 #[must_use]
410 pub fn with_leader_election(mut self, kv: Arc<dyn klieo_core::KvStore>) -> Self {
411 self.leader_kv = Some(kv);
412 self
413 }
414
415 #[must_use]
427 pub fn with_tenant_binding(mut self, kv: Arc<dyn klieo_core::KvStore>) -> Self {
428 self.tenant_kv = Some(kv);
429 self.tenant_strict = false;
430 self
431 }
432
433 #[must_use]
441 pub fn with_tenant_binding_strict(mut self, kv: Arc<dyn klieo_core::KvStore>) -> Self {
442 self.tenant_kv = Some(kv);
443 self.tenant_strict = true;
444 self
445 }
446
447 #[must_use]
454 pub fn with_leader_ttl(mut self, ttl: std::time::Duration) -> Self {
455 self.leader_ttl = Some(ttl);
456 self
457 }
458
459 #[must_use]
464 pub fn with_leader_heartbeat_interval(mut self, interval: std::time::Duration) -> Self {
465 self.leader_heartbeat_interval = Some(interval);
466 self
467 }
468
469 #[must_use]
474 pub fn with_max_failover_attempts(mut self, cap: u32) -> Self {
475 self.max_failover_attempts = Some(cap);
476 self
477 }
478
479 #[must_use]
493 pub fn with_kv_reaper(mut self, interval: std::time::Duration) -> Self {
494 self.kv_reaper_interval = Some(interval);
495 self
496 }
497
498 pub fn build(self) -> Result<A2aDispatcher, A2aBuilderError> {
508 if self.subscribe_cancels {
509 return Err(A2aBuilderError::CancelRequiresArc);
510 }
511 self.build_inner()
512 }
513
514 fn build_inner(self) -> Result<A2aDispatcher, A2aBuilderError> {
515 let handler = self.handler.ok_or(A2aBuilderError::MissingHandler)?;
516 let authenticator = self
517 .authenticator
518 .ok_or(A2aBuilderError::MissingAuthenticator)?;
519 let pubsub = self.pubsub.ok_or(A2aBuilderError::MissingPubsub)?;
520 let permits = self.publish_concurrency.unwrap_or(DEFAULT_PUBLISH_PERMITS);
521 let leader_registry = self.leader_kv.map(|kv| {
522 klieo_core::LeaderRegistry::new(
523 kv,
524 "klieo-leaders".into(),
525 uuid::Uuid::new_v4().to_string(),
526 )
527 });
528 let profile = self.profile;
529 profile.validate(
530 self.tenant_kv.is_some(),
531 Some(authenticator.allows_anonymous()),
532 )?;
533 let tenant_strict = self.tenant_strict || profile.requires_strict_binding();
534 let ownership_registry = self.tenant_kv.map(|kv| {
535 let bucket = "klieo-tenants".into();
536 if tenant_strict {
537 klieo_core::OwnershipRegistry::new_strict(kv, bucket)
538 } else {
539 klieo_core::OwnershipRegistry::new(kv, bucket)
540 }
541 });
542 if profile.requires_strict_binding() || profile.requires_named_principal() {
543 tracing::warn!(
544 target: "klieo.security",
545 cwe = 639,
546 "regulated multi-tenant profile active on this replica; \
547 cross-replica tenant isolation assumes ALL replicas run the \
548 same profile — a lenient peer reintroduces CWE-639. Fleet \
549 homogeneity is NOT verified by this replica."
550 );
551 }
552 let leader_ttl = self.leader_ttl.unwrap_or(LEADER_TTL);
553 let leader_heartbeat_interval = self.leader_heartbeat_interval.unwrap_or(leader_ttl / 2);
554 let max_failover_attempts = self
555 .max_failover_attempts
556 .unwrap_or(klieo_core::FAILOVER_ATTEMPT_CAP);
557 Ok(A2aDispatcher {
558 handler,
559 authenticator,
560 pubsub,
561 cancel_registry: klieo_core::CancelRegistry::new(),
562 publish_permits: Arc::new(Semaphore::new(permits)),
563 leader_registry,
564 ownership_registry,
565 leader_ttl,
566 leader_heartbeat_interval,
567 max_failover_attempts,
568 kv_reaper_interval: self.kv_reaper_interval,
569 })
570 }
571
572 pub fn build_arc(self) -> Result<Arc<A2aDispatcher>, A2aBuilderError> {
576 let spawn_subscriber = self.subscribe_cancels;
577 let dispatcher = Arc::new(self.build_inner()?);
578 if spawn_subscriber {
579 dispatcher.with_cancel_subscription();
580 }
581 Ok(dispatcher)
582 }
583}
584
585pub struct A2aDispatcher {
590 handler: Arc<dyn A2aHandler>,
591 authenticator: Arc<dyn Authenticator>,
592 pubsub: Arc<dyn klieo_core::Pubsub>,
593 cancel_registry: klieo_core::CancelRegistry<String>,
594 publish_permits: Arc<Semaphore>,
595 leader_registry: Option<klieo_core::LeaderRegistry>,
596 ownership_registry: Option<klieo_core::OwnershipRegistry>,
597 leader_ttl: std::time::Duration,
598 leader_heartbeat_interval: std::time::Duration,
599 max_failover_attempts: u32,
600 kv_reaper_interval: Option<std::time::Duration>,
601}
602
603impl A2aDispatcher {
604 pub fn builder() -> A2aDispatcherBuilder {
614 A2aDispatcherBuilder::default()
615 }
616
617 pub fn new(
624 handler: Arc<dyn A2aHandler>,
625 authenticator: Arc<dyn Authenticator>,
626 pubsub: Arc<dyn klieo_core::Pubsub>,
627 ) -> Self {
628 Self {
629 handler,
630 authenticator,
631 pubsub,
632 cancel_registry: klieo_core::CancelRegistry::new(),
633 publish_permits: Arc::new(Semaphore::new(DEFAULT_PUBLISH_PERMITS)),
634 leader_registry: None,
635 ownership_registry: None,
636 leader_ttl: LEADER_TTL,
637 leader_heartbeat_interval: LEADER_TTL / 2,
638 max_failover_attempts: klieo_core::FAILOVER_ATTEMPT_CAP,
639 kv_reaper_interval: None,
640 }
641 }
642
643 pub fn leader_ttl(&self) -> std::time::Duration {
647 self.leader_ttl
648 }
649
650 pub fn leader_heartbeat_interval(&self) -> std::time::Duration {
654 self.leader_heartbeat_interval
655 }
656
657 pub fn max_failover_attempts(&self) -> u32 {
662 self.max_failover_attempts
663 }
664
665 pub fn kv_reaper_interval(&self) -> Option<std::time::Duration> {
670 self.kv_reaper_interval
671 }
672
673 pub fn leader_registry(&self) -> Option<&klieo_core::LeaderRegistry> {
677 self.leader_registry.as_ref()
678 }
679
680 pub fn ownership_registry(&self) -> Option<&klieo_core::OwnershipRegistry> {
684 self.ownership_registry.as_ref()
685 }
686
687 #[must_use]
695 pub fn with_publish_concurrency(mut self, permits: usize) -> Self {
696 self.publish_permits = Arc::new(Semaphore::new(permits));
697 self
698 }
699
700 pub fn with_in_process_pubsub(
704 handler: Arc<dyn A2aHandler>,
705 authenticator: Arc<dyn Authenticator>,
706 ) -> Self {
707 let bus = klieo_bus_memory::MemoryBus::new();
708 Self::new(handler, authenticator, bus.pubsub.clone())
709 }
710
711 #[cfg(feature = "test-fixtures")]
722 pub fn local(handler: Arc<dyn A2aHandler>) -> Self {
723 let bus = klieo_bus_memory::MemoryBus::new();
724 Self::new(
725 handler,
726 Arc::new(klieo_auth_common::AllowAnonymous),
727 bus.pubsub.clone(),
728 )
729 }
730
731 pub fn authenticator(&self) -> &Arc<dyn Authenticator> {
735 &self.authenticator
736 }
737
738 pub fn handler(&self) -> &Arc<dyn A2aHandler> {
745 &self.handler
746 }
747
748 pub fn event_sink(&self) -> TaskEventSink {
754 TaskEventSink::with_permits(self.pubsub.clone(), self.publish_permits.clone())
755 }
756
757 pub fn pubsub(&self) -> &Arc<dyn klieo_core::Pubsub> {
759 &self.pubsub
760 }
761
762 pub fn publish_permits(&self) -> &Arc<Semaphore> {
769 &self.publish_permits
770 }
771
772 pub fn cancel_registry(&self) -> &klieo_core::CancelRegistry<String> {
780 &self.cancel_registry
781 }
782
783 pub async fn publish_cancel(&self, task_id: &str) -> Result<(), A2aError> {
808 klieo_core::cancel::publish_cancel_signal(&self.pubsub, A2A_CANCEL_SUBJECT_PREFIX, task_id)
809 .await?;
810 Ok(())
811 }
812
813 pub fn with_cancel_subscription(self: &Arc<Self>) {
826 klieo_core::cancel::spawn_wildcard_cancel_subscriber(
827 self.pubsub.clone(),
828 A2A_CANCEL_SUBJECT_PATTERN.to_string(),
829 A2A_CANCEL_SUBJECT_PREFIX.to_string(),
830 self.cancel_registry.clone(),
831 A2A_CANCEL_LOG_TARGET,
832 );
833 }
834
835 #[allow(clippy::too_many_lines)]
842 #[instrument(
843 skip_all,
844 fields(rpc.system = "klieo-a2a", rpc.method = tracing::field::Empty),
845 )]
846 pub async fn dispatch(&self, ctx: &RequestContext, payload: &[u8]) -> JsonRpcResponse {
847 let req: JsonRpcRequest = match serde_json::from_slice(payload) {
848 Ok(r) => r,
849 Err(e) => return A2aError::Json(e).to_json_rpc_error(Value::Null),
850 };
851 tracing::Span::current().record("rpc.method", req.method.as_str());
852 let method = match A2aMethod::from_str(&req.method) {
853 Ok(m) => m,
854 Err(e) => return e.to_json_rpc_error(req.id.clone()),
855 };
856 let id = req.id.clone();
857 if let Some(identity) = ctx.caller.as_ref() {
858 if let Err(e) = self
865 .authenticator
866 .authorize_method(identity, method.wire_name())
867 .await
868 {
869 warn!(
870 target: "security",
871 error = %e,
872 method = %req.method,
873 "a2a authorize_method rejected",
874 );
875 return A2aError::Unauthorized(e.to_string()).to_json_rpc_error(id);
876 }
877 } else if !self.authenticator.allows_anonymous() {
878 warn!(
879 target: "security",
880 method = %req.method,
881 "a2a anonymous caller rejected (authenticator requires an identity)",
882 );
883 return A2aError::Unauthorized("anonymous caller not permitted".into())
884 .to_json_rpc_error(id);
885 }
886 let params_raw = req.params;
887 let result: Result<Value, A2aError> = match method {
888 A2aMethod::SendMessage => {
889 run_method::<SendMessageParams, _, _>(params_raw, |p| async move {
890 let result = self.handler.send_message(ctx, p).await?;
891 if let SendMessageResult::Task(task) = &result {
898 self.claim_owner_persistent(ctx, &task.id).await?;
899 }
900 Ok(result)
901 })
902 .await
903 }
904 A2aMethod::SendStreamingMessage => {
908 Err(A2aError::MethodNotFound("SendStreamingMessage".into()))
909 }
910 A2aMethod::GetTask => {
911 run_method::<GetTaskParams, _, _>(params_raw, |p| async move {
912 self.enforce_owner(ctx, &p.id).await?;
913 self.handler.get_task(ctx, p).await
914 })
915 .await
916 }
917 A2aMethod::ListTasks => {
918 run_method::<ListTasksParams, _, _>(params_raw, |p| async move {
919 let mut result = self.handler.list_tasks(ctx, p).await?;
920 self.retain_owned_tasks(ctx, &mut result.tasks).await?;
921 Ok(result)
922 })
923 .await
924 }
925 A2aMethod::CancelTask => {
926 run_method::<CancelTaskParams, _, _>(params_raw, |p| async move {
927 self.enforce_owner(ctx, &p.id).await?;
930 self.handler.cancel_task(ctx, p).await
931 })
932 .await
933 }
934 A2aMethod::SubscribeToTask => Err(A2aError::MethodNotFound("SubscribeToTask".into())),
935 A2aMethod::CreateTaskPushNotificationConfig => {
936 run_method::<PushNotificationConfigParams, _, _>(params_raw, |p| async move {
937 crate::ssrf::validate_callback_url(&p.url)?;
942 self.enforce_owner(ctx, &p.taskId).await?;
943 self.handler
944 .create_task_push_notification_config(ctx, p)
945 .await
946 })
947 .await
948 }
949 A2aMethod::GetTaskPushNotificationConfig => {
950 run_method::<GetPushNotificationConfigParams, _, _>(params_raw, |p| async move {
951 self.enforce_owner(ctx, &p.taskId).await?;
952 self.handler.get_task_push_notification_config(ctx, p).await
953 })
954 .await
955 }
956 A2aMethod::ListTaskPushNotificationConfigs => {
957 run_method::<ListPushNotificationConfigsParams, _, _>(params_raw, |p| async move {
958 self.enforce_owner(ctx, &p.taskId).await?;
959 self.handler
960 .list_task_push_notification_configs(ctx, p)
961 .await
962 })
963 .await
964 }
965 A2aMethod::DeleteTaskPushNotificationConfig => {
966 run_method::<DeletePushNotificationConfigParams, _, _>(params_raw, |p| async move {
967 self.enforce_owner(ctx, &p.taskId).await?;
968 self.handler
969 .delete_task_push_notification_config(ctx, p)
970 .await
971 })
972 .await
973 }
974 A2aMethod::GetExtendedAgentCard => {
975 run_method::<GetExtendedAgentCardParams, _, _>(params_raw, |p| async move {
976 self.handler.get_extended_agent_card(ctx, p).await
977 })
978 .await
979 }
980 };
981 match result {
982 Ok(value) => JsonRpcResponse {
983 jsonrpc: "2.0".into(),
984 id,
985 result: Some(value),
986 error: None,
987 },
988 Err(e) => {
989 log_internal_before_wire_seam(&e, &req.method);
990 e.to_json_rpc_error(id)
991 }
992 }
993 }
994
995 pub async fn handle_request(&self, headers: A2aHeaders, payload: &[u8]) -> JsonRpcResponse {
1007 match self.authenticator.authenticate(&headers, payload).await {
1008 Ok(identity) => {
1009 let ctx = RequestContext::new(headers, Some(identity));
1010 self.dispatch(&ctx, payload).await
1011 }
1012 Err(e) => {
1013 warn!(target: "security", error = %e, "a2a auth rejected");
1014 let req_id = serde_json::from_slice::<JsonRpcRequest>(payload)
1015 .map(|r| r.id)
1016 .unwrap_or(Value::Null);
1017 JsonRpcResponse {
1018 jsonrpc: "2.0".into(),
1019 id: req_id,
1020 result: None,
1021 error: Some(JsonRpcError {
1022 code: codes::UNAUTHENTICATED,
1023 message: "Unauthenticated".into(),
1024 data: None,
1025 }),
1026 }
1027 }
1028 }
1029 }
1030
1031 pub async fn handle_streaming(
1041 &self,
1042 headers: A2aHeaders,
1043 payload: &[u8],
1044 task_store: &crate::task_store::A2aTaskStore,
1045 cancel: tokio_util::sync::CancellationToken,
1046 last_event_id: Option<u64>,
1047 resume_buffer: Arc<dyn klieo_core::resume::ResumeBuffer>,
1048 ) -> Result<TaskEventStream, A2aError> {
1049 match self.authenticator.authenticate(&headers, payload).await {
1050 Ok(identity) => {
1051 let ctx = RequestContext::new(headers, Some(identity)).with_cancel(cancel);
1052 self.dispatch_streaming(&ctx, payload, task_store, last_event_id, resume_buffer)
1053 .await
1054 }
1055 Err(e) => {
1056 warn!(target: "security", error = %e, "a2a auth rejected (streaming path)");
1057 Err(A2aError::Unauthorized(e.to_string()))
1058 }
1059 }
1060 }
1061
1062 #[instrument(
1084 skip_all,
1085 fields(rpc.system = "klieo-a2a", rpc.method = tracing::field::Empty),
1086 err,
1087 )]
1088 pub async fn dispatch_streaming(
1089 &self,
1090 ctx: &RequestContext,
1091 payload: &[u8],
1092 task_store: &crate::task_store::A2aTaskStore,
1093 last_event_id: Option<u64>,
1094 resume_buffer: Arc<dyn klieo_core::resume::ResumeBuffer>,
1095 ) -> Result<TaskEventStream, A2aError> {
1096 let req: JsonRpcRequest =
1097 serde_json::from_slice(payload).map_err(|e| A2aError::InvalidParams(e.to_string()))?;
1098 tracing::Span::current().record("rpc.method", req.method.as_str());
1099 let method = A2aMethod::from_str(&req.method)
1100 .map_err(|_| A2aError::MethodNotFound(req.method.clone()))?;
1101 if let Some(identity) = ctx.caller.as_ref() {
1102 if let Err(e) = self
1105 .authenticator
1106 .authorize_method(identity, method.wire_name())
1107 .await
1108 {
1109 warn!(
1110 target: "security",
1111 error = %e,
1112 method = %req.method,
1113 "a2a authorize_method rejected (streaming path)",
1114 );
1115 return Err(A2aError::Unauthorized(e.to_string()));
1116 }
1117 } else if !self.authenticator.allows_anonymous() {
1118 warn!(
1119 target: "security",
1120 method = %req.method,
1121 "a2a anonymous caller rejected (streaming path; authenticator requires an identity)",
1122 );
1123 return Err(A2aError::Unauthorized(
1124 "anonymous caller not permitted".into(),
1125 ));
1126 }
1127 let params_raw = req.params;
1128 match method {
1129 A2aMethod::SendStreamingMessage => {
1130 let params: SendMessageParams = serde_json::from_value(params_raw)
1131 .map_err(|e| A2aError::InvalidParams(e.to_string()))?;
1132 self.dispatch_send_streaming(ctx, params, task_store).await
1135 }
1136 A2aMethod::SubscribeToTask => {
1137 let params: SubscribeToTaskParams = serde_json::from_value(params_raw)
1138 .map_err(|e| A2aError::InvalidParams(e.to_string()))?;
1139 self.dispatch_subscribe_to_task(
1140 ctx,
1141 params,
1142 task_store,
1143 last_event_id,
1144 resume_buffer,
1145 )
1146 .await
1147 }
1148 _ => Err(A2aError::MethodNotFound(req.method)),
1149 }
1150 }
1151
1152 #[instrument(
1153 skip_all,
1154 fields(
1155 rpc.system = "klieo-a2a",
1156 rpc.method = "SendStreamingMessage",
1157 klieo.stream_id = tracing::field::Empty,
1158 ),
1159 err,
1160 )]
1161 async fn dispatch_send_streaming(
1162 &self,
1163 ctx: &RequestContext,
1164 params: SendMessageParams,
1165 task_store: &crate::task_store::A2aTaskStore,
1166 ) -> Result<TaskEventStream, A2aError> {
1167 self.run_streaming_invoke(ctx, params, task_store, None)
1168 .await
1169 }
1170
1171 async fn run_streaming_invoke(
1178 &self,
1179 ctx: &RequestContext,
1180 params: SendMessageParams,
1181 task_store: &crate::task_store::A2aTaskStore,
1182 existing_leader: Option<klieo_core::LeaderHandle>,
1183 ) -> Result<TaskEventStream, A2aError> {
1184 match self
1185 .handler
1186 .send_streaming_message(ctx, params.clone())
1187 .await
1188 {
1189 Ok(stream) => return Ok(stream),
1190 Err(A2aError::MethodNotFound(_)) => {}
1191 Err(other) => return Err(other),
1192 }
1193 let payload_bytes_for_failover = match existing_leader {
1200 Some(_) => None,
1201 None => match serde_json::to_vec(¶ms) {
1202 Ok(v) => Some(Bytes::from(v)),
1203 Err(e) => {
1204 warn!(
1205 target: "a2a.failover",
1206 error = %e,
1207 "send-streaming params serialise for failover failed; \
1208 proceeding without cached payload",
1209 );
1210 None
1211 }
1212 },
1213 };
1214 let principal_for_failover = match existing_leader {
1215 Some(_) => None,
1216 None => ctx
1217 .caller
1218 .as_ref()
1219 .filter(|id| !id.is_anonymous())
1220 .map(|id| id.as_str().to_string()),
1221 };
1222 let result = self.handler.send_message(ctx, params).await?;
1223 let task = match result {
1224 SendMessageResult::Task(t) => t,
1225 SendMessageResult::Message(_) => {
1226 return Ok(Box::pin(futures::stream::empty()));
1229 }
1230 };
1231 let task_id = task.id.clone();
1232 tracing::Span::current().record("klieo.stream_id", task_id.as_str());
1233 let terminal = task.status.is_terminal();
1234 let event_id = task_store.next_event_id(&task_id);
1235 let initial = TaskEvent::new(
1236 task_id.clone(),
1237 task.status,
1238 task.history.last().cloned(),
1239 terminal,
1240 )
1241 .with_event_id(event_id);
1242 if terminal {
1243 return Ok(Box::pin(futures::stream::once(futures::future::ready(
1248 initial,
1249 ))));
1250 }
1251 let initial_stream = futures::stream::once(futures::future::ready(initial));
1252 let tail = self.task_event_stream(task_id.clone()).await?;
1253 let chained = futures::StreamExt::chain(initial_stream, tail);
1254 let leader_handle = match existing_leader {
1260 Some(handle) => Some(handle),
1261 None => {
1262 self.try_claim_leader(&task_id, payload_bytes_for_failover, principal_for_failover)
1263 .await
1264 }
1265 };
1266 let ownership_handle = self.try_claim_ownership(ctx, &task_id).await?;
1267 Ok(Box::pin(LeaderHoldStream {
1268 inner: chained,
1269 _leader: leader_handle,
1270 _ownership: ownership_handle,
1271 }))
1272 }
1273
1274 async fn try_claim_ownership(
1285 &self,
1286 ctx: &RequestContext,
1287 task_id: &str,
1288 ) -> Result<Option<klieo_core::OwnershipHandle>, A2aError> {
1289 let Some(registry) = self.ownership_registry.as_ref() else {
1290 return Ok(None);
1291 };
1292 let Some(caller) = ctx.caller.as_ref() else {
1293 return Ok(None);
1294 };
1295 if caller.is_anonymous() {
1296 return Ok(None);
1297 }
1298 let key = ownership_key(task_id);
1299 let principal = caller.as_str().to_string();
1300 match registry.claim_guarded(key, principal).await {
1301 klieo_core::OwnershipClaim::Claimed(handle) => Ok(Some(handle)),
1302 klieo_core::OwnershipClaim::Proceed => Ok(None),
1303 klieo_core::OwnershipClaim::Unavailable => Err(A2aError::Server(
1304 "ownership store unavailable; invoke denied (strict tenant binding)".into(),
1305 )),
1306 _ => Err(A2aError::Server(
1309 "ownership claim inconclusive; invoke denied".into(),
1310 )),
1311 }
1312 }
1313
1314 async fn claim_owner_persistent(
1322 &self,
1323 ctx: &RequestContext,
1324 task_id: &str,
1325 ) -> Result<(), A2aError> {
1326 let Some(registry) = self.ownership_registry.as_ref() else {
1327 return Ok(());
1328 };
1329 let Some(caller) = ctx.caller.as_ref() else {
1330 return Ok(());
1331 };
1332 if caller.is_anonymous() {
1333 return Ok(());
1334 }
1335 let key = ownership_key(task_id);
1336 match registry
1337 .record_owner(key, caller.as_str().to_string())
1338 .await
1339 {
1340 klieo_core::OwnershipRecord::Recorded | klieo_core::OwnershipRecord::Proceed => Ok(()),
1341 klieo_core::OwnershipRecord::Unavailable => Err(A2aError::Server(
1342 "ownership store unavailable; invoke denied (strict tenant binding)".into(),
1343 )),
1344 _ => Err(A2aError::Server(
1347 "ownership record inconclusive; invoke denied".into(),
1348 )),
1349 }
1350 }
1351
1352 async fn enforce_owner(&self, ctx: &RequestContext, task_id: &str) -> Result<(), A2aError> {
1367 let Some(registry) = self.ownership_registry.as_ref() else {
1368 return Ok(());
1369 };
1370 let Some(caller) = ctx.caller.as_ref() else {
1371 return Ok(());
1372 };
1373 if caller.is_anonymous() {
1374 return Ok(());
1375 }
1376 let key = ownership_key(task_id);
1377 match registry.check_owner(&key, caller.as_str()).await {
1378 klieo_core::OwnershipCheck::Allowed => Ok(()),
1379 klieo_core::OwnershipCheck::Denied => {
1380 warn!(
1384 target: "a2a.tenants",
1385 task_id = %task_id,
1386 principal = %caller.as_str(),
1387 "ownership mismatch on SubscribeToTask; denying as TaskNotFound",
1388 );
1389 Err(A2aError::TaskNotFound(task_id.to_string()))
1390 }
1391 klieo_core::OwnershipCheck::Unavailable => {
1392 warn!(
1393 target: "a2a.tenants",
1394 task_id = %task_id,
1395 principal = %caller.as_str(),
1396 "ownership store unavailable; request denied (strict tenant binding)",
1397 );
1398 Err(A2aError::OwnershipUnavailable)
1399 }
1400 other => {
1401 warn!(
1402 target: "a2a.tenants",
1403 task_id = %task_id,
1404 principal = %caller.as_str(),
1405 verdict = ?other,
1406 "inconclusive ownership verdict; request denied",
1407 );
1408 Err(A2aError::OwnershipUnavailable)
1409 }
1410 }
1411 }
1412
1413 async fn retain_owned_tasks(
1426 &self,
1427 ctx: &RequestContext,
1428 tasks: &mut Vec<Task>,
1429 ) -> Result<(), A2aError> {
1430 let Some(registry) = self.ownership_registry.as_ref() else {
1431 return Ok(());
1432 };
1433 let Some(caller) = ctx.caller.as_ref() else {
1434 return Ok(());
1435 };
1436 if caller.is_anonymous() {
1437 return Ok(());
1438 }
1439 let verdicts = futures::future::join_all(tasks.iter().map(|task| {
1440 let key = ownership_key(&task.id);
1441 async move { registry.check_owner(&key, caller.as_str()).await }
1442 }))
1443 .await;
1444
1445 let mut owned = Vec::with_capacity(tasks.len());
1446 for (task, verdict) in std::mem::take(tasks).into_iter().zip(verdicts) {
1447 match verdict {
1448 klieo_core::OwnershipCheck::Allowed => owned.push(task),
1449 klieo_core::OwnershipCheck::Denied => {}
1450 klieo_core::OwnershipCheck::Unavailable => {
1451 warn!(
1452 target: "a2a.tenants",
1453 principal = %caller.as_str(),
1454 "ownership store unavailable; ListTasks denied (strict tenant binding)",
1455 );
1456 return Err(A2aError::OwnershipUnavailable);
1457 }
1458 other => {
1459 warn!(
1460 target: "a2a.tenants",
1461 principal = %caller.as_str(),
1462 verdict = ?other,
1463 "inconclusive ownership verdict; ListTasks denied",
1464 );
1465 return Err(A2aError::OwnershipUnavailable);
1466 }
1467 }
1468 }
1469 *tasks = owned;
1470 Ok(())
1471 }
1472
1473 async fn try_claim_leader(
1486 &self,
1487 task_id: &str,
1488 payload: Option<Bytes>,
1489 principal: Option<String>,
1490 ) -> Option<klieo_core::LeaderHandle> {
1491 let registry = self.leader_registry.as_ref()?;
1492 let key = format!("{A2A_LEADER_KEY_PREFIX}{task_id}");
1493 match registry
1494 .claim_with_heartbeat(
1495 key,
1496 self.leader_ttl,
1497 self.leader_heartbeat_interval,
1498 payload,
1499 principal,
1500 )
1501 .await
1502 {
1503 Ok(handle) => Some(handle),
1504 Err(e) => {
1505 warn!(
1506 target: "a2a.leader",
1507 task_id = %task_id,
1508 error = %e,
1509 "leader claim failed; degrading to no-claim (orphan detection \
1510 disabled for this stream)",
1511 );
1512 None
1513 }
1514 }
1515 }
1516
1517 async fn write_orphan_terminal_frame(
1532 &self,
1533 task_id: &str,
1534 resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
1535 ) -> Option<()> {
1536 let max_id = max_event_id(resume_buffer, task_id).await?;
1537 let next_id = max_id + 1;
1538 let frame = leader_died_sse_frame_bytes(task_id, next_id);
1539 if let Err(e) = resume_buffer.record(task_id, next_id, frame).await {
1540 warn!(
1541 target: "a2a.leader",
1542 task_id = %task_id,
1543 next_id,
1544 error = %e,
1545 "orphan terminal record failed; skipping orphan write",
1546 );
1547 return None;
1548 }
1549 if let Err(e) = resume_buffer.close(task_id).await {
1550 warn!(
1551 target: "a2a.leader",
1552 task_id = %task_id,
1553 error = %e,
1554 "orphan terminal close failed; resume buffer may retain stale stream",
1555 );
1556 }
1557 tracing::error!(
1558 target: "a2a.leader",
1559 task_id = %task_id,
1560 next_id,
1561 "stream leader died; emitted LEADER_DIED terminal frame at max+1",
1562 );
1563 Some(())
1564 }
1565
1566 #[cfg(not(feature = "test-fixtures"))]
1590 async fn handle_dead_leader_orphan(
1591 &self,
1592 ctx: &RequestContext,
1593 task_id: &str,
1594 task_store: &crate::task_store::A2aTaskStore,
1595 resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
1596 ) -> Result<Option<TaskEventStream>, A2aError> {
1597 self.handle_dead_leader_orphan_impl(ctx, task_id, task_store, resume_buffer)
1598 .await
1599 }
1600
1601 #[cfg(feature = "test-fixtures")]
1605 pub async fn handle_dead_leader_orphan(
1606 &self,
1607 ctx: &RequestContext,
1608 task_id: &str,
1609 task_store: &crate::task_store::A2aTaskStore,
1610 resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
1611 ) -> Result<Option<TaskEventStream>, A2aError> {
1612 self.handle_dead_leader_orphan_impl(ctx, task_id, task_store, resume_buffer)
1613 .await
1614 }
1615
1616 async fn handle_dead_leader_orphan_impl(
1617 &self,
1618 ctx: &RequestContext,
1619 task_id: &str,
1620 task_store: &crate::task_store::A2aTaskStore,
1621 resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
1622 ) -> Result<Option<TaskEventStream>, A2aError> {
1623 let registry = match self.leader_registry.as_ref() {
1624 Some(reg) => reg,
1625 None => return Ok(None),
1626 };
1627 let key = format!("{A2A_LEADER_KEY_PREFIX}{task_id}");
1628 let lookup = registry.lookup_entry_with_revision(&key).await;
1629 let Some((entry, prior_rev)) = lookup_ok_or_log(&key, lookup) else {
1630 return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1633 };
1634 if !self.handler.is_idempotent() {
1635 tracing::debug!(
1636 target: "a2a.failover",
1637 task_id,
1638 "handler not idempotent; emitting terminate frame",
1639 );
1640 return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1641 }
1642 if entry.attempt >= self.max_failover_attempts {
1643 tracing::warn!(
1644 target: "a2a.failover",
1645 task_id,
1646 attempt = entry.attempt,
1647 cap = self.max_failover_attempts,
1648 "failover attempt cap reached; emitting terminate frame",
1649 );
1650 return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1651 }
1652 let Some(payload_bytes) = entry.payload.clone() else {
1653 tracing::debug!(
1654 target: "a2a.failover",
1655 task_id,
1656 "no cached payload on leader entry; emitting terminate frame",
1657 );
1658 return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1659 };
1660 let new_handle = match registry
1661 .claim_with_attempt_cas_and_heartbeat(
1662 key.clone(),
1663 self.leader_ttl,
1664 self.leader_heartbeat_interval,
1665 prior_rev,
1666 &entry,
1667 )
1668 .await
1669 {
1670 Ok(h) => h,
1671 Err(klieo_core::BusError::CasConflict { .. }) => {
1672 tracing::info!(
1673 target: "a2a.failover",
1674 task_id,
1675 "another follower won the CAS race; emitting terminate frame",
1676 );
1677 return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1678 }
1679 Err(e) => {
1680 tracing::warn!(
1681 target: "a2a.failover",
1682 task_id,
1683 error = %e,
1684 "CAS claim failed; emitting terminate frame",
1685 );
1686 return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1687 }
1688 };
1689 self.record_failover_marker(task_id, &entry, &new_handle, resume_buffer)
1690 .await;
1691 let parsed_params: SendMessageParams = match serde_json::from_slice(&payload_bytes) {
1692 Ok(p) => p,
1693 Err(e) => {
1694 tracing::error!(
1695 target: "a2a.failover",
1696 task_id,
1697 error = %e,
1698 "cached payload parse failed; emitting terminate frame",
1699 );
1700 return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1701 }
1702 };
1703 let reinvoke_ctx = self.fabricate_reinvoke_ctx(ctx, entry.principal.as_deref());
1704 let stream = self
1705 .run_streaming_invoke(&reinvoke_ctx, parsed_params, task_store, Some(new_handle))
1706 .await?;
1707 Ok(Some(stream))
1708 }
1709
1710 async fn record_failover_marker(
1715 &self,
1716 task_id: &str,
1717 prior: &klieo_core::LeaderEntry,
1718 new_handle: &klieo_core::LeaderHandle,
1719 resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
1720 ) {
1721 let Some(max) = max_event_id(resume_buffer, task_id).await else {
1722 return;
1725 };
1726 let marker_id = max + 1;
1727 let frame = failover_reinvoke_sse_frame_bytes(
1728 task_id,
1729 marker_id,
1730 prior.attempt + 1,
1731 new_handle.replica_id(),
1732 );
1733 if let Err(e) = resume_buffer.record(task_id, marker_id, frame).await {
1734 warn!(
1735 target: "a2a.failover",
1736 task_id,
1737 marker_id,
1738 error = %e,
1739 "failover-reinvoke marker record failed; continuing without marker",
1740 );
1741 }
1742 }
1743
1744 fn fabricate_reinvoke_ctx(
1751 &self,
1752 ctx: &RequestContext,
1753 principal: Option<&str>,
1754 ) -> RequestContext {
1755 let caller = principal
1756 .map(|p| klieo_auth_common::Identity::new(p.to_string()))
1757 .or_else(|| Some(klieo_auth_common::Identity::anonymous()));
1758 RequestContext::new(ctx.headers.clone(), caller).with_cancel(ctx.cancel.clone())
1759 }
1760
1761 async fn terminate_orphan_a2a(
1767 &self,
1768 task_id: &str,
1769 resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
1770 ) -> Result<Option<TaskEventStream>, A2aError> {
1771 if self
1772 .write_orphan_terminal_frame(task_id, resume_buffer)
1773 .await
1774 .is_some()
1775 {
1776 return Err(A2aError::LeaderDied {
1777 stream_id: format!("{A2A_LEADER_KEY_PREFIX}{task_id}"),
1778 });
1779 }
1780 Ok(None)
1781 }
1782
1783 async fn probe_leader(&self, task_id: &str) -> LeaderProbe {
1791 let Some(registry) = self.leader_registry.as_ref() else {
1792 return LeaderProbe::NoRegistry;
1793 };
1794 let key = format!("{A2A_LEADER_KEY_PREFIX}{task_id}");
1795 match registry.is_alive(&key).await {
1796 Ok(true) => LeaderProbe::Alive,
1797 Ok(false) => LeaderProbe::Dead,
1798 Err(e) => {
1799 warn!(
1800 target: "a2a.leader",
1801 task_id = %task_id,
1802 error = %e,
1803 "is_alive probe failed; treating as alive (fail-open per ADR-020)",
1804 );
1805 LeaderProbe::Alive
1806 }
1807 }
1808 }
1809
1810 #[instrument(
1828 skip_all,
1829 fields(
1830 rpc.system = "klieo-a2a",
1831 rpc.method = "SubscribeToTask",
1832 klieo.stream_id = %params.id,
1833 ),
1834 err,
1835 )]
1836 async fn dispatch_subscribe_to_task(
1837 &self,
1838 ctx: &RequestContext,
1839 params: SubscribeToTaskParams,
1840 task_store: &crate::task_store::A2aTaskStore,
1841 last_event_id: Option<u64>,
1842 resume_buffer: Arc<dyn klieo_core::resume::ResumeBuffer>,
1843 ) -> Result<TaskEventStream, A2aError> {
1844 self.enforce_owner(ctx, ¶ms.id).await?;
1853 if let LeaderProbe::Dead = self.probe_leader(¶ms.id).await {
1861 if let Some(stream) = self
1862 .handle_dead_leader_orphan(ctx, ¶ms.id, task_store, resume_buffer.as_ref())
1863 .await?
1864 {
1865 return Ok(stream);
1866 }
1867 }
1868 if let Some(since) = last_event_id {
1869 match self.handler.subscribe_to_task(ctx, params.clone()).await {
1880 Ok(stream) => return Ok(stream),
1881 Err(A2aError::MethodNotFound(_)) => {}
1882 Err(other) => return Err(other),
1883 }
1884
1885 let task_id_for_tail = params.id.clone();
1888 let tail_stream = self.task_event_stream(task_id_for_tail).await?;
1889
1890 match resume_buffer.replay(¶ms.id, since).await {
1891 Ok(replay_stream) => {
1892 use std::sync::atomic::{AtomicU64, Ordering};
1893 use std::sync::Arc as StdArc;
1894
1895 let max_replayed = StdArc::new(AtomicU64::new(since));
1896 let max_for_replay = max_replayed.clone();
1897 let task_id_for_replay = params.id.clone();
1898 let replay = replay_stream.map(move |(id, bytes)| {
1899 max_for_replay.fetch_max(id, Ordering::SeqCst);
1900 serde_json::from_slice::<TaskEvent>(&bytes)
1901 .unwrap_or_else(|_| TaskEvent::synthetic_corrupt(&task_id_for_replay))
1902 });
1903 let max_for_tail = max_replayed.clone();
1904 let tail = tail_stream.filter(move |ev: &TaskEvent| {
1905 ev.event_id > max_for_tail.load(Ordering::SeqCst)
1906 });
1907 return Ok(Box::pin(futures::StreamExt::chain(replay, tail)));
1908 }
1909 Err(klieo_core::resume::ResumeError::Expired { since_id }) => {
1910 return Err(A2aError::ResumeBufferExpired { since_id });
1911 }
1912 Err(klieo_core::resume::ResumeError::NotFound(_)) => { }
1913 Err(klieo_core::resume::ResumeError::Backend(e)) => {
1914 tracing::warn!(
1915 target: "a2a.resume",
1916 error = %e,
1917 "resume buffer backend error; falling back to snapshot+tail"
1918 );
1919 }
1920 Err(_) => {}
1923 }
1924 }
1925 self.subscribe_snapshot_then_tail(ctx, params, task_store)
1926 .await
1927 }
1928
1929 #[instrument(
1936 skip_all,
1937 fields(klieo.stream_id = %params.id),
1938 level = "debug",
1939 err,
1940 )]
1941 async fn subscribe_snapshot_then_tail(
1942 &self,
1943 ctx: &RequestContext,
1944 params: SubscribeToTaskParams,
1945 task_store: &crate::task_store::A2aTaskStore,
1946 ) -> Result<TaskEventStream, A2aError> {
1947 match self.handler.subscribe_to_task(ctx, params.clone()).await {
1948 Ok(stream) => return Ok(stream),
1949 Err(A2aError::MethodNotFound(_)) => {}
1950 Err(other) => return Err(other),
1951 }
1952 let task = task_store
1953 .get(¶ms.id)
1954 .await?
1955 .ok_or_else(|| A2aError::TaskNotFound(params.id.clone()))?;
1956 let terminal = task.status.is_terminal();
1957 let event_id = task_store.next_event_id(&task.id);
1958 let initial = TaskEvent::new(
1959 task.id.clone(),
1960 task.status,
1961 task.history.last().cloned(),
1962 terminal,
1963 )
1964 .with_event_id(event_id);
1965 self.task_event_stream_with_initial(task.id, initial, terminal)
1966 .await
1967 }
1968
1969 #[instrument(
1976 skip_all,
1977 fields(klieo.stream_id = %task_id),
1978 level = "debug",
1979 err,
1980 )]
1981 async fn task_event_stream(&self, task_id: String) -> Result<TaskEventStream, A2aError> {
1982 klieo_core::validate_subject_token(&task_id)?;
1983 let subject = format!("klieo.a2a.task.{task_id}");
1984 let durable = DurableName::new(format!("klieo-eph-{}", uuid::Uuid::new_v4()));
1985 let msg_stream = self.pubsub.subscribe(&subject, durable).await?;
1986 let stream = futures::stream::unfold((msg_stream, false), move |(mut ms, done)| {
1987 let task_id = task_id.clone();
1988 async move {
1989 if done {
1990 return None;
1991 }
1992 loop {
1993 match ms.next().await {
1994 None => return None,
1995 Some(Err(e)) => {
1996 warn!(
1997 target: "a2a",
1998 task_id = %task_id,
1999 error = %e,
2000 "task event stream bus error; skipping",
2001 );
2002 continue;
2003 }
2004 Some(Ok(msg)) => {
2005 if let Err(ack_err) = msg.ack.ack().await {
2007 tracing::warn!(target: "a2a", error = %ack_err, "ack failed; message may redeliver");
2008 }
2009 let parent_cx = klieo_core::extract_traceparent(&msg.headers);
2014 let decode_span = tracing::info_span!(
2015 "task_event_decode",
2016 messaging.system = "klieo-bus",
2017 messaging.destination = %format!("klieo.a2a.task.{task_id}"),
2018 messaging.operation = "receive",
2019 klieo.stream_id = %task_id,
2020 );
2021 decode_span.set_parent(parent_cx);
2022 let _enter = decode_span.enter();
2023 match serde_json::from_slice::<TaskEvent>(&msg.payload) {
2024 Err(e) => {
2025 warn!(
2026 target: "a2a",
2027 task_id = %task_id,
2028 error = %e,
2029 "task event decode error; skipping",
2030 );
2031 continue;
2032 }
2033 Ok(ev) => {
2034 let terminal = ev.final_event;
2035 return Some((ev, (ms, terminal)));
2036 }
2037 }
2038 }
2039 }
2040 }
2041 }
2042 });
2043 Ok(Box::pin(stream))
2044 }
2045
2046 async fn task_event_stream_with_initial(
2047 &self,
2048 task_id: String,
2049 initial: TaskEvent,
2050 initial_terminal: bool,
2051 ) -> Result<TaskEventStream, A2aError> {
2052 use futures::StreamExt as FutExt;
2053
2054 let initial_stream = futures::stream::once(futures::future::ready(initial));
2055 if initial_terminal {
2056 return Ok(Box::pin(initial_stream));
2059 }
2060 let tail = self.task_event_stream(task_id).await?;
2061 Ok(Box::pin(FutExt::chain(initial_stream, tail)))
2062 }
2063}
2064
2065pub struct A2aServer {
2067 app_prefix: String,
2068 agent_id: String,
2069 dispatcher: Arc<A2aDispatcher>,
2070 pubsub: Arc<dyn Pubsub>,
2071}
2072
2073impl A2aServer {
2074 pub fn new(
2084 app_prefix: String,
2085 agent_id: String,
2086 handler: Arc<dyn A2aHandler>,
2087 authenticator: Arc<dyn Authenticator>,
2088 pubsub: Arc<dyn Pubsub>,
2089 ) -> Self {
2090 let dispatcher = Arc::new(A2aDispatcher::new(handler, authenticator, pubsub.clone()));
2091 Self {
2092 app_prefix,
2093 agent_id,
2094 dispatcher,
2095 pubsub,
2096 }
2097 }
2098
2099 pub fn dispatcher(&self) -> &Arc<A2aDispatcher> {
2102 &self.dispatcher
2103 }
2104
2105 pub fn subject(&self) -> String {
2107 format!("{}.a2a.{}.rpc", self.app_prefix, self.agent_id)
2108 }
2109
2110 pub async fn run(self) -> Result<(), A2aError> {
2112 let subject = self.subject();
2113 let durable = DurableName::new(format!("a2a-server-{}", self.agent_id));
2114 let mut stream = self.pubsub.subscribe(&subject, durable).await?;
2115 while let Some(item) = stream.next().await {
2116 match item {
2117 Ok(msg) => {
2118 self.handle_one(msg).await;
2119 }
2120 Err(e) => {
2121 error!(error = %e, "a2a subscribe stream error; exiting loop");
2122 return Err(A2aError::Bus(e));
2123 }
2124 }
2125 }
2126 Ok(())
2127 }
2128
2129 async fn handle_one(&self, msg: Msg) {
2130 let reply_to = match msg.headers.get("Reply-To") {
2131 Some(s) => s.clone(),
2132 None => {
2133 warn!("a2a request missing Reply-To header; dropping");
2134 if let Err(ack_err) = msg.ack.ack().await {
2135 tracing::warn!(target: "a2a", error = %ack_err, "ack failed; message may redeliver");
2136 }
2137 return;
2138 }
2139 };
2140 let headers = A2aHeaders::decode_from(&msg.headers);
2141 let response = self.dispatcher.handle_request(headers, &msg.payload).await;
2142 let bytes = match serde_json::to_vec(&response) {
2143 Ok(b) => Bytes::from(b),
2144 Err(e) => {
2145 error!(error = %e, "encode response");
2146 if let Err(ack_err) = msg.ack.ack().await {
2147 tracing::warn!(target: "a2a", error = %ack_err, "ack failed; message may redeliver");
2148 }
2149 return;
2150 }
2151 };
2152 if let Err(e) = self.pubsub.publish(&reply_to, bytes, Headers::new()).await {
2153 error!(error = %e, "publish reply");
2154 }
2155 if let Err(ack_err) = msg.ack.ack().await {
2156 tracing::warn!(target: "a2a", error = %ack_err, "ack failed; message may redeliver");
2157 }
2158 }
2159}
2160
2161async fn max_event_id(
2166 resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
2167 stream_id: &str,
2168) -> Option<u64> {
2169 let mut replay = match resume_buffer.replay(stream_id, 0).await {
2170 Ok(stream) => stream,
2171 Err(klieo_core::resume::ResumeError::NotFound(_)) => return None,
2172 Err(e) => {
2173 warn!(
2174 target: "a2a.leader",
2175 stream_id,
2176 error = %e,
2177 "max_event_id replay failed; skipping orphan terminal write",
2178 );
2179 return None;
2180 }
2181 };
2182 let mut highest: Option<u64> = None;
2183 while let Some((id, _)) = tokio_stream::StreamExt::next(&mut replay).await {
2184 highest = Some(match highest {
2185 Some(current) => current.max(id),
2186 None => id,
2187 });
2188 }
2189 highest
2190}
2191
2192fn lookup_ok_or_log(
2196 key: &str,
2197 lookup: Result<Option<(klieo_core::LeaderEntry, klieo_core::Revision)>, klieo_core::BusError>,
2198) -> Option<(klieo_core::LeaderEntry, klieo_core::Revision)> {
2199 match lookup {
2200 Ok(Some(pair)) => Some(pair),
2201 Ok(None) => None,
2202 Err(e) => {
2203 warn!(
2204 target: "a2a.failover",
2205 key,
2206 error = %e,
2207 "leader entry lookup_with_revision failed; falling back to terminate",
2208 );
2209 None
2210 }
2211 }
2212}
2213
2214fn failover_reinvoke_sse_frame_bytes(
2222 task_id: &str,
2223 event_id: u64,
2224 attempt: u32,
2225 new_replica_id: &str,
2226) -> bytes::Bytes {
2227 let payload = serde_json::json!({
2228 "jsonrpc": "2.0",
2229 "id": serde_json::Value::Null,
2230 "event": "failover-reinvoke",
2231 "data": {
2232 "stream_id": format!("{A2A_LEADER_KEY_PREFIX}{task_id}"),
2233 "attempt": attempt,
2234 "by_replica": new_replica_id,
2235 },
2236 "event_id": event_id,
2237 });
2238 bytes::Bytes::from(serde_json::to_vec(&payload).unwrap_or_default())
2239}
2240
2241fn leader_died_sse_frame_bytes(task_id: &str, event_id: u64) -> bytes::Bytes {
2246 let payload = serde_json::json!({
2247 "jsonrpc": "2.0",
2248 "id": serde_json::Value::Null,
2249 "error": {
2250 "code": codes::LEADER_DIED,
2251 "message": "stream leader died",
2252 "data": { "stream_id": format!("{A2A_LEADER_KEY_PREFIX}{task_id}") }
2253 },
2254 "event_id": event_id,
2255 });
2256 bytes::Bytes::from(serde_json::to_vec(&payload).unwrap_or_default())
2257}
2258
2259pub(crate) fn log_internal_before_wire_seam(err: &A2aError, method: &str) {
2269 let internal = matches!(
2270 err,
2271 A2aError::Bus(_)
2272 | A2aError::Server(_)
2273 | A2aError::Misconfigured(_)
2274 | A2aError::Internal { .. }
2275 );
2276 if internal {
2277 error!(
2278 target: "a2a.wire",
2279 method = %method,
2280 error = %err,
2281 source = ?std::error::Error::source(err),
2282 "internal error mapped to JSON-RPC wire envelope",
2283 );
2284 } else {
2285 warn!(
2286 target: "a2a.wire",
2287 method = %method,
2288 error = %err,
2289 "client-class error mapped to JSON-RPC wire envelope",
2290 );
2291 }
2292}
2293
2294async fn run_method<P, T, Fut>(raw: Value, run: impl FnOnce(P) -> Fut) -> Result<Value, A2aError>
2297where
2298 P: DeserializeOwned,
2299 T: Serialize,
2300 Fut: std::future::Future<Output = Result<T, A2aError>>,
2301{
2302 let params: P =
2303 serde_json::from_value(raw).map_err(|e| A2aError::InvalidParams(e.to_string()))?;
2304 let typed = run(params).await?;
2305 serde_json::to_value(typed).map_err(A2aError::from)
2306}
2307
2308#[cfg(test)]
2309mod tests {
2310 use super::*;
2311 use crate::envelope::A2aHeaders;
2312 use crate::handler::EchoHandler;
2313 use crate::task_store::A2aTaskStore;
2314 use crate::types::{Task, TaskStatus};
2315 use klieo_auth_common::{AllowAnonymous, Identity};
2316 use klieo_bus_memory::MemoryBus;
2317
2318 fn anon_ctx() -> RequestContext {
2319 RequestContext::new(
2320 A2aHeaders::decode_from(&klieo_core::Headers::new()),
2321 Some(Identity::anonymous()),
2322 )
2323 }
2324
2325 fn make_store_with_dispatcher(dispatcher: &A2aDispatcher) -> A2aTaskStore {
2326 A2aTaskStore::new(
2327 Arc::new(MemoryBus::new()).kv.clone(),
2328 crate::task_store::DEFAULT_BUCKET.into(),
2329 )
2330 .with_event_sink(dispatcher.event_sink())
2331 }
2332
2333 fn make_task(id: &str, status: TaskStatus) -> Task {
2334 Task {
2335 id: id.into(),
2336 contextId: "ctx-1".into(),
2337 status,
2338 artifacts: vec![],
2339 history: vec![],
2340 metadata: None,
2341 }
2342 }
2343
2344 fn noop_resume_buffer() -> Arc<dyn klieo_core::resume::ResumeBuffer> {
2345 Arc::new(klieo_core::resume::NoopResumeBuffer)
2346 }
2347
2348 #[tokio::test]
2349 async fn local_wires_anonymous_auth_and_in_process_pubsub_and_dispatches() {
2350 let dispatcher = A2aDispatcher::local(Arc::new(EchoHandler::default()));
2351 let ctx = anon_ctx();
2352 let payload = br#"{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"messageId":"m1","role":"user","parts":[{"type":"text","content":"hi"}],"extensions":[],"referenceTaskIds":[]}}}"#;
2353 let resp = dispatcher.dispatch(&ctx, payload).await;
2354 assert_eq!(resp.id, serde_json::json!(1));
2355 assert!(resp.result.is_some());
2356 assert!(resp.error.is_none());
2357 }
2358
2359 #[tokio::test]
2360 async fn dispatcher_routes_send_message_to_handler() {
2361 let dispatcher = A2aDispatcher::with_in_process_pubsub(
2362 Arc::new(EchoHandler::default()),
2363 Arc::new(AllowAnonymous),
2364 );
2365 let ctx = anon_ctx();
2366 let payload = br#"{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"messageId":"m1","role":"user","parts":[{"type":"text","content":"hi"}],"extensions":[],"referenceTaskIds":[]}}}"#;
2367 let resp = dispatcher.dispatch(&ctx, payload).await;
2368 assert_eq!(resp.id, serde_json::json!(1));
2369 assert!(resp.result.is_some());
2370 assert!(resp.error.is_none());
2371 }
2372
2373 #[tokio::test]
2374 async fn legacy_slash_form_method_name_reaches_the_same_handler_as_camel_case() {
2375 let dispatcher = A2aDispatcher::with_in_process_pubsub(
2379 Arc::new(EchoHandler::default()),
2380 Arc::new(AllowAnonymous),
2381 );
2382 let ctx = anon_ctx();
2383 let legacy_payload = br#"{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{"messageId":"m1","role":"user","parts":[{"text":"hi"}],"extensions":[],"referenceTaskIds":[]}}}"#;
2384 let resp = dispatcher.dispatch(&ctx, legacy_payload).await;
2385 assert!(
2386 resp.error.is_none(),
2387 "legacy alias must reach send_message, not MethodNotFound: {:?}",
2388 resp.error
2389 );
2390 let result = resp.result.expect("send_message result");
2391 assert!(
2392 result.get("id").is_some() || result.get("messageId").is_some(),
2393 "expected a Task or Message result shape, got {result}"
2394 );
2395 }
2396
2397 #[tokio::test]
2398 async fn authorize_method_sees_canonical_wire_name_for_legacy_alias() {
2399 struct DenyCancelTask;
2403 #[async_trait::async_trait]
2404 impl klieo_auth_common::Authenticator for DenyCancelTask {
2405 async fn authenticate(
2406 &self,
2407 _headers: &dyn klieo_auth_common::Headers,
2408 _payload: &[u8],
2409 ) -> Result<Identity, klieo_auth_common::AuthError> {
2410 Ok(Identity::new("scoped-caller"))
2411 }
2412
2413 async fn authorize_method(
2414 &self,
2415 _identity: &Identity,
2416 method: &str,
2417 ) -> Result<(), klieo_auth_common::AuthError> {
2418 if method == "CancelTask" {
2419 Err(klieo_auth_common::AuthError::Rejected(
2420 "scope mismatch".into(),
2421 ))
2422 } else {
2423 Ok(())
2424 }
2425 }
2426 }
2427
2428 let dispatcher = A2aDispatcher::with_in_process_pubsub(
2429 Arc::new(EchoHandler::default()),
2430 Arc::new(DenyCancelTask),
2431 );
2432 let ctx = named_ctx("scoped-caller");
2433 let payload = br#"{"jsonrpc":"2.0","id":1,"method":"tasks/cancel","params":{"id":"t-1"}}"#;
2435 let resp = dispatcher.dispatch(&ctx, payload).await;
2436 let err = resp
2437 .error
2438 .expect("legacy alias for a denied method must still be denied");
2439 assert_eq!(
2440 err.code,
2441 codes::UNAUTHENTICATED,
2442 "expected the scope-deny to surface as Unauthorized, got: {err:?}"
2443 );
2444 }
2445
2446 #[tokio::test]
2447 async fn create_push_notification_config_rejects_loopback_callback_url() {
2448 let dispatcher = A2aDispatcher::with_in_process_pubsub(
2449 Arc::new(EchoHandler::default()),
2450 Arc::new(AllowAnonymous),
2451 );
2452 let ctx = anon_ctx();
2453 let payload = br#"{"jsonrpc":"2.0","id":1,"method":"CreateTaskPushNotificationConfig","params":{"taskId":"t-1","url":"http://127.0.0.1:8080/hook"}}"#;
2454 let resp = dispatcher.dispatch(&ctx, payload).await;
2455 let err = resp
2456 .error
2457 .expect("loopback callback URL must be rejected before reaching the handler");
2458 assert_eq!(
2459 err.code,
2460 codes::INVALID_PARAMS,
2461 "expected -32602 Invalid params for the SSRF guard, got: {err:?}"
2462 );
2463 assert!(
2464 resp.result.is_none(),
2465 "a rejected config must not return a partial result"
2466 );
2467 }
2468
2469 #[tokio::test]
2470 async fn create_push_notification_config_accepts_public_https_callback_url() {
2471 let dispatcher = A2aDispatcher::with_in_process_pubsub(
2472 Arc::new(EchoHandler::default()),
2473 Arc::new(AllowAnonymous),
2474 );
2475 let ctx = anon_ctx();
2476 let payload = br#"{"jsonrpc":"2.0","id":1,"method":"CreateTaskPushNotificationConfig","params":{"taskId":"t-1","url":"https://example.test/hook"}}"#;
2477 let resp = dispatcher.dispatch(&ctx, payload).await;
2478 let err = resp.error.expect("EchoHandler default is MethodNotFound");
2482 assert_eq!(err.code, codes::METHOD_NOT_FOUND);
2483 }
2484
2485 fn named_ctx(principal: &str) -> RequestContext {
2486 RequestContext::new(
2487 A2aHeaders::decode_from(&klieo_core::Headers::new()),
2488 Some(Identity::new(principal)),
2489 )
2490 }
2491
2492 const SEND_HI: &[u8] = br#"{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"messageId":"m1","role":"user","parts":[{"type":"text","content":"hi"}],"extensions":[],"referenceTaskIds":[]}}}"#;
2493
2494 #[tokio::test]
2495 async fn non_streaming_send_message_claims_ownership_so_foreign_get_task_is_denied() {
2496 let kv = Arc::new(MemoryBus::new()).kv.clone();
2497 let dispatcher = A2aDispatcher::builder()
2498 .handler(Arc::new(EchoHandler::default()))
2499 .authenticator(Arc::new(AllowAnonymous))
2500 .with_in_process_pubsub()
2501 .with_tenant_binding(kv)
2502 .build()
2503 .unwrap();
2504
2505 let created = dispatcher.dispatch(&named_ctx("alice"), SEND_HI).await;
2507 let task_id = created.result.expect("SendMessage returns a task")["id"]
2508 .as_str()
2509 .expect("task id is a string")
2510 .to_string();
2511 let get = format!(
2512 r#"{{"jsonrpc":"2.0","id":2,"method":"GetTask","params":{{"id":"{task_id}"}}}}"#
2513 );
2514
2515 let foreign = dispatcher.dispatch(&named_ctx("bob"), get.as_bytes()).await;
2518 assert!(
2519 foreign.error.is_some(),
2520 "foreign GetTask must be denied, got result {:?}",
2521 foreign.result
2522 );
2523
2524 let owner = dispatcher
2526 .dispatch(&named_ctx("alice"), get.as_bytes())
2527 .await;
2528 assert!(
2529 owner.error.is_none(),
2530 "owner GetTask must succeed, got error {:?}",
2531 owner.error
2532 );
2533 assert!(owner.result.is_some());
2534 }
2535
2536 #[tokio::test]
2537 async fn dispatcher_streaming_subscribe_to_task_replays_current_state() {
2538 let dispatcher = A2aDispatcher::with_in_process_pubsub(
2539 Arc::new(EchoHandler::default()),
2540 Arc::new(AllowAnonymous),
2541 );
2542 let store = make_store_with_dispatcher(&dispatcher);
2543
2544 let task = make_task("t-1", TaskStatus::Working);
2545 store.put(&task).await.unwrap();
2546
2547 let payload =
2548 br#"{"jsonrpc":"2.0","id":1,"method":"SubscribeToTask","params":{"id":"t-1"}}"#;
2549 let ctx = anon_ctx();
2550 let mut stream = dispatcher
2551 .dispatch_streaming(&ctx, payload, &store, None, noop_resume_buffer())
2552 .await
2553 .unwrap();
2554
2555 let first = stream.next().await.expect("stream produced no first event");
2556 assert_eq!(first.task_id, "t-1");
2557 assert!(matches!(first.status, TaskStatus::Working));
2558 assert!(!first.final_event, "Working is not terminal");
2559 }
2560
2561 #[tokio::test]
2562 async fn dispatcher_streaming_rejects_unknown_task() {
2563 let dispatcher = A2aDispatcher::with_in_process_pubsub(
2564 Arc::new(EchoHandler::default()),
2565 Arc::new(AllowAnonymous),
2566 );
2567 let store = make_store_with_dispatcher(&dispatcher);
2568
2569 let payload =
2570 br#"{"jsonrpc":"2.0","id":1,"method":"SubscribeToTask","params":{"id":"nope"}}"#;
2571 let ctx = anon_ctx();
2572 let result = dispatcher
2573 .dispatch_streaming(&ctx, payload, &store, None, noop_resume_buffer())
2574 .await;
2575 assert!(matches!(result, Err(A2aError::TaskNotFound(_))));
2576 }
2577
2578 struct NoAnonAuthn;
2579
2580 #[async_trait::async_trait]
2581 impl Authenticator for NoAnonAuthn {
2582 async fn authenticate(
2583 &self,
2584 _headers: &dyn klieo_auth_common::Headers,
2585 _payload: &[u8],
2586 ) -> Result<Identity, klieo_auth_common::AuthError> {
2587 Ok(Identity::new("svc"))
2588 }
2589 }
2590
2591 #[tokio::test]
2592 async fn dispatcher_streaming_rejects_anonymous_under_named_authenticator() {
2593 let dispatcher = A2aDispatcher::with_in_process_pubsub(
2596 Arc::new(EchoHandler::default()),
2597 Arc::new(NoAnonAuthn),
2598 );
2599 let store = make_store_with_dispatcher(&dispatcher);
2600 let payload = br#"{"jsonrpc":"2.0","id":1,"method":"SendStreamingMessage","params":{"message":{"messageId":"m1","role":"user","parts":[{"type":"text","content":"hi"}],"extensions":[],"referenceTaskIds":[]}}}"#;
2601 let ctx = RequestContext::new(A2aHeaders::decode_from(&klieo_core::Headers::new()), None);
2602 let result = dispatcher
2603 .dispatch_streaming(&ctx, payload, &store, None, noop_resume_buffer())
2604 .await;
2605 assert!(matches!(result, Err(A2aError::Unauthorized(_))));
2606 }
2607
2608 #[tokio::test]
2609 async fn dispatcher_streaming_subscribe_terminal_task_closes_stream() {
2610 let dispatcher = A2aDispatcher::with_in_process_pubsub(
2611 Arc::new(EchoHandler::default()),
2612 Arc::new(AllowAnonymous),
2613 );
2614 let store = make_store_with_dispatcher(&dispatcher);
2615
2616 let task = make_task("t-done", TaskStatus::Completed);
2617 store.put(&task).await.unwrap();
2618
2619 let payload =
2620 br#"{"jsonrpc":"2.0","id":1,"method":"SubscribeToTask","params":{"id":"t-done"}}"#;
2621 let ctx = anon_ctx();
2622 let mut stream = dispatcher
2623 .dispatch_streaming(&ctx, payload, &store, None, noop_resume_buffer())
2624 .await
2625 .unwrap();
2626
2627 let first = stream.next().await.expect("expected replay event");
2628 assert_eq!(first.task_id, "t-done");
2629 assert!(first.final_event, "Completed must set final_event=true");
2630 assert!(stream.next().await.is_none(), "stream must be exhausted");
2632 }
2633
2634 #[tokio::test]
2635 async fn dispatcher_streaming_send_streaming_message_returns_stream() {
2636 let dispatcher = A2aDispatcher::with_in_process_pubsub(
2637 Arc::new(EchoHandler::default()),
2638 Arc::new(AllowAnonymous),
2639 );
2640 let store = make_store_with_dispatcher(&dispatcher);
2641
2642 let payload = br#"{"jsonrpc":"2.0","id":1,"method":"SendStreamingMessage","params":{"message":{"messageId":"m1","role":"user","parts":[{"type":"text","content":"hi"}],"extensions":[],"referenceTaskIds":[]}}}"#;
2643 let ctx = anon_ctx();
2644 let result = dispatcher
2645 .dispatch_streaming(&ctx, payload, &store, None, noop_resume_buffer())
2646 .await;
2647 assert!(
2648 result.is_ok(),
2649 "dispatch_streaming should return Ok for SendStreamingMessage"
2650 );
2651 }
2652
2653 #[tokio::test]
2654 async fn handle_streaming_threads_cancel_token_into_request_context() {
2655 use crate::auth::RequestContext;
2656 use crate::error::A2aError;
2657 use crate::handler::A2aHandler;
2658 use crate::server::TaskEventStream;
2659 use crate::types::SendMessageParams;
2660 use klieo_auth_common::{AllowAnonymous, Authenticator};
2661 use std::sync::{Arc, Mutex};
2662 use tokio_util::sync::CancellationToken;
2663
2664 struct CancelSpy {
2665 observed: Mutex<Option<CancellationToken>>,
2666 }
2667 #[async_trait::async_trait]
2668 impl A2aHandler for CancelSpy {
2669 async fn send_streaming_message(
2670 &self,
2671 ctx: &RequestContext,
2672 _: SendMessageParams,
2673 ) -> Result<TaskEventStream, A2aError> {
2674 *self.observed.lock().unwrap() = Some(ctx.cancel.clone());
2675 Ok(Box::pin(futures::stream::empty()))
2676 }
2677 }
2678
2679 let spy = Arc::new(CancelSpy {
2680 observed: Mutex::new(None),
2681 });
2682 let dispatcher = A2aDispatcher::with_in_process_pubsub(
2683 spy.clone() as Arc<dyn A2aHandler>,
2684 Arc::new(AllowAnonymous) as Arc<dyn Authenticator>,
2685 );
2686 let store = A2aTaskStore::new(
2687 Arc::new(klieo_bus_memory::MemoryBus::new()).kv.clone(),
2688 crate::task_store::DEFAULT_BUCKET.into(),
2689 )
2690 .with_event_sink(dispatcher.event_sink());
2691
2692 let token = CancellationToken::new();
2693 let body = serde_json::to_vec(&serde_json::json!({
2694 "jsonrpc": "2.0",
2695 "id": 1,
2696 "method": "SendStreamingMessage",
2697 "params": {
2698 "message": {
2699 "messageId": "m-spy",
2700 "role": "user",
2701 "parts": [],
2702 "extensions": [],
2703 "referenceTaskIds": []
2704 }
2705 }
2706 }))
2707 .unwrap();
2708
2709 let _ = dispatcher
2710 .handle_streaming(
2711 A2aHeaders::decode_from(&klieo_core::Headers::new()),
2712 &body,
2713 &store,
2714 token.clone(),
2715 None,
2716 noop_resume_buffer(),
2717 )
2718 .await
2719 .unwrap();
2720
2721 token.cancel();
2722 let observed = spy.observed.lock().unwrap().clone().unwrap();
2723 assert!(
2724 observed.is_cancelled(),
2725 "cancel must propagate from handle_streaming arg"
2726 );
2727 }
2728
2729 #[tokio::test]
2730 async fn task_event_sink_publishes_to_per_task_subject() {
2731 let bus = klieo_bus_memory::MemoryBus::new();
2732 let pubsub: std::sync::Arc<dyn klieo_core::Pubsub> = bus.pubsub.clone();
2733 let sink = TaskEventSink::new(pubsub.clone());
2734
2735 let durable = klieo_core::DurableName::new("test-eph");
2736 let mut stream = pubsub
2737 .subscribe("klieo.a2a.task.t-1", durable)
2738 .await
2739 .unwrap();
2740
2741 let event = TaskEvent::new(
2742 "t-1".to_string(),
2743 crate::types::TaskStatus::Submitted,
2744 None,
2745 false,
2746 )
2747 .with_event_id(1);
2748
2749 sink.send(event.clone()).await.unwrap();
2750
2751 use tokio_stream::StreamExt as _;
2752 let msg = tokio::time::timeout(std::time::Duration::from_millis(500), stream.next())
2753 .await
2754 .expect("timeout")
2755 .expect("stream ended")
2756 .expect("subscribe err");
2757 let decoded: TaskEvent = serde_json::from_slice(&msg.payload).unwrap();
2758 assert_eq!(decoded.task_id, "t-1");
2759 assert_eq!(decoded.event_id, 1);
2760 msg.ack.ack().await.unwrap();
2761 }
2762}
2763
2764#[cfg(test)]
2765mod profile_tests {
2766 use super::*;
2767 use crate::handler::EchoHandler;
2768 use klieo_auth_common::{AllowAnonymous, AuthError, Headers, Identity};
2769 use klieo_core::DeploymentProfile;
2770
2771 struct NamedAuthn;
2772
2773 #[async_trait::async_trait]
2774 impl Authenticator for NamedAuthn {
2775 async fn authenticate(
2776 &self,
2777 _headers: &dyn Headers,
2778 _payload: &[u8],
2779 ) -> Result<Identity, AuthError> {
2780 Ok(Identity::new("alice"))
2781 }
2782 }
2783
2784 fn builder_with(auth: Arc<dyn Authenticator>) -> A2aDispatcherBuilder {
2785 let bus = klieo_bus_memory::MemoryBus::new();
2786 A2aDispatcher::builder()
2787 .handler(Arc::new(EchoHandler::default()))
2788 .authenticator(auth)
2789 .pubsub(bus.pubsub.clone())
2790 }
2791
2792 #[test]
2793 fn regulated_without_tenant_kv_fails_closed() {
2794 let err = builder_with(Arc::new(NamedAuthn))
2795 .profile(DeploymentProfile::RegulatedMultiTenant)
2796 .build()
2797 .err()
2798 .expect("expected RegulatedProfile error");
2799 assert!(matches!(
2800 err,
2801 A2aBuilderError::RegulatedProfile(klieo_core::ProfileViolation::MissingTenantKv)
2802 ));
2803 }
2804
2805 #[test]
2806 fn regulated_with_anonymous_auth_fails_closed() {
2807 let bus = klieo_bus_memory::MemoryBus::new();
2808 let err = builder_with(Arc::new(AllowAnonymous))
2809 .with_tenant_binding(bus.kv.clone())
2810 .profile(DeploymentProfile::RegulatedMultiTenant)
2811 .build()
2812 .err()
2813 .expect("expected RegulatedProfile error");
2814 assert!(matches!(
2815 err,
2816 A2aBuilderError::RegulatedProfile(klieo_core::ProfileViolation::AnonymousAuth)
2817 ));
2818 }
2819
2820 #[test]
2821 fn regulated_forces_strict_over_lenient_binding() {
2822 let bus = klieo_bus_memory::MemoryBus::new();
2823 let dispatcher = builder_with(Arc::new(NamedAuthn))
2824 .with_tenant_binding(bus.kv.clone())
2825 .profile(DeploymentProfile::RegulatedMultiTenant)
2826 .build()
2827 .expect("regulated build with named auth + kv must succeed");
2828 assert_eq!(
2829 dispatcher
2830 .ownership_registry
2831 .as_ref()
2832 .map(|r| r.is_strict()),
2833 Some(true),
2834 "regulated profile must force a strict registry even over lenient binding"
2835 );
2836 }
2837
2838 #[test]
2839 fn unprofiled_keeps_lenient_binding() {
2840 let bus = klieo_bus_memory::MemoryBus::new();
2841 let dispatcher = builder_with(Arc::new(NamedAuthn))
2842 .with_tenant_binding(bus.kv.clone())
2843 .build()
2844 .expect("unprofiled build ok");
2845 assert_eq!(
2846 dispatcher
2847 .ownership_registry
2848 .as_ref()
2849 .map(|r| r.is_strict()),
2850 Some(false)
2851 );
2852 }
2853
2854 fn tenant_bound_dispatcher() -> A2aDispatcher {
2858 let bus = klieo_bus_memory::MemoryBus::new();
2859 A2aDispatcher::builder()
2860 .handler(Arc::new(EchoHandler::default()))
2861 .authenticator(Arc::new(AllowAnonymous))
2862 .pubsub(bus.pubsub.clone())
2863 .with_tenant_binding(bus.kv.clone())
2864 .build()
2865 .expect("tenant-bound dispatcher builds")
2866 }
2867
2868 fn named_ctx(principal: &str) -> RequestContext {
2869 RequestContext::new(
2870 A2aHeaders::decode_from(&klieo_core::Headers::new()),
2871 Some(Identity::new(principal)),
2872 )
2873 }
2874
2875 const SEND_MESSAGE_PAYLOAD: &[u8] = br#"{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"messageId":"m1","role":"user","parts":[{"type":"text","content":"hi"}],"extensions":[],"referenceTaskIds":[]}}}"#;
2876
2877 #[tokio::test]
2878 async fn anonymous_caller_rejected_when_authenticator_requires_identity() {
2879 let dispatcher = builder_with(Arc::new(NamedAuthn)).build().expect("build");
2880 let ctx = RequestContext::new(A2aHeaders::decode_from(&klieo_core::Headers::new()), None);
2881 let resp = dispatcher.dispatch(&ctx, SEND_MESSAGE_PAYLOAD).await;
2882 let err = resp.error.expect("anonymous caller must be rejected");
2883 assert_eq!(err.code, codes::UNAUTHENTICATED);
2884 assert!(
2885 resp.result.is_none(),
2886 "rejected request must carry no result"
2887 );
2888 }
2889
2890 fn hi_params() -> crate::types::SendMessageParams {
2893 serde_json::from_value(serde_json::json!({
2894 "message": {
2895 "messageId": "m1",
2896 "role": "user",
2897 "parts": [{"type": "text", "content": "hi"}],
2898 "extensions": [],
2899 "referenceTaskIds": []
2900 }
2901 }))
2902 .expect("valid SendMessageParams")
2903 }
2904
2905 async fn create_task(dispatcher: &A2aDispatcher, ctx: &RequestContext) -> String {
2906 let resp = dispatcher.dispatch(ctx, SEND_MESSAGE_PAYLOAD).await;
2907 resp.result
2908 .expect("SendMessage result")
2909 .get("id")
2910 .and_then(|v| v.as_str())
2911 .expect("created task carries an id")
2912 .to_string()
2913 }
2914
2915 #[tokio::test]
2916 async fn anonymous_send_message_records_no_ownership() {
2917 let dispatcher = tenant_bound_dispatcher();
2921 let anon = RequestContext::new(
2922 A2aHeaders::decode_from(&klieo_core::Headers::new()),
2923 Some(Identity::anonymous()),
2924 );
2925 let created = dispatcher.dispatch(&anon, SEND_MESSAGE_PAYLOAD).await;
2926 let task_id = created.result.expect("SendMessage returns a task")["id"]
2927 .as_str()
2928 .expect("task id is a string")
2929 .to_string();
2930 let owner = dispatcher
2931 .ownership_registry()
2932 .expect("registry wired")
2933 .lookup(&format!("{A2A_OWNERSHIP_KEY_PREFIX}{task_id}"))
2934 .await
2935 .expect("lookup ok");
2936 assert!(
2937 owner.is_none(),
2938 "anonymous SendMessage must not write an ownership entry, got {owner:?}"
2939 );
2940 }
2941
2942 #[must_use]
2945 async fn claim_for(
2946 dispatcher: &A2aDispatcher,
2947 task_id: &str,
2948 principal: &str,
2949 ) -> klieo_core::OwnershipHandle {
2950 dispatcher
2951 .ownership_registry()
2952 .expect("ownership registry wired")
2953 .claim(
2954 format!("{A2A_OWNERSHIP_KEY_PREFIX}{task_id}"),
2955 principal.into(),
2956 )
2957 .await
2958 .expect("claim ownership")
2959 }
2960
2961 fn by_id_request(method: &str, task_id: &str) -> Vec<u8> {
2962 serde_json::to_vec(&serde_json::json!({
2963 "jsonrpc": "2.0",
2964 "id": 2,
2965 "method": method,
2966 "params": { "id": task_id },
2967 }))
2968 .expect("encode by-id request")
2969 }
2970
2971 #[tokio::test]
2972 async fn get_task_owner_reads_but_foreign_principal_denied_as_not_found() {
2973 let dispatcher = tenant_bound_dispatcher();
2974 let alice = named_ctx("alice");
2975 let task_id = create_task(&dispatcher, &alice).await;
2976 let _ownership = claim_for(&dispatcher, &task_id, "alice").await;
2977
2978 let request = by_id_request("GetTask", &task_id);
2979
2980 let owner = dispatcher.dispatch(&alice, &request).await;
2981 assert!(owner.error.is_none(), "owner must read own task: {owner:?}");
2982 assert_eq!(
2983 owner
2984 .result
2985 .expect("owner result")
2986 .get("id")
2987 .and_then(|v| v.as_str()),
2988 Some(task_id.as_str()),
2989 );
2990
2991 let bob = named_ctx("bob");
2993 let foreign = dispatcher.dispatch(&bob, &request).await;
2994 let err = foreign.error.expect("foreign principal must be denied");
2995 assert_eq!(err.code, codes::SERVER_ERROR);
2996 assert_ne!(
2997 err.code,
2998 codes::UNAUTHENTICATED,
2999 "deny must not surface -32001 (would leak existence info)",
3000 );
3001 assert!(
3002 err.message.contains("task not found"),
3003 "deny-as-not-found expected, got: {}",
3004 err.message,
3005 );
3006 assert!(foreign.result.is_none(), "must not leak the task body");
3007 }
3008
3009 #[tokio::test]
3010 async fn cancel_task_foreign_principal_denied_before_mutation() {
3011 let dispatcher = tenant_bound_dispatcher();
3012 let alice = named_ctx("alice");
3013 let task_id = create_task(&dispatcher, &alice).await;
3014 let _ownership = claim_for(&dispatcher, &task_id, "alice").await;
3015
3016 let bob = named_ctx("bob");
3018 let cancel = dispatcher
3019 .dispatch(&bob, &by_id_request("CancelTask", &task_id))
3020 .await;
3021 let err = cancel.error.expect("foreign cancel must be denied");
3022 assert_eq!(err.code, codes::SERVER_ERROR);
3023 assert!(err.message.contains("task not found"));
3024
3025 let owner_view = dispatcher
3028 .dispatch(&alice, &by_id_request("GetTask", &task_id))
3029 .await;
3030 let status = owner_view
3031 .result
3032 .expect("owner result")
3033 .get("status")
3034 .and_then(|v| v.as_str())
3035 .map(str::to_string);
3036 assert_ne!(
3037 status.as_deref(),
3038 Some("canceled"),
3039 "foreign cancel must not mutate the task",
3040 );
3041 }
3042
3043 struct UnavailableKv;
3046
3047 #[async_trait::async_trait]
3048 impl klieo_core::KvStore for UnavailableKv {
3049 async fn get(
3050 &self,
3051 _: &str,
3052 _: &str,
3053 ) -> Result<Option<klieo_core::KvEntry>, klieo_core::BusError> {
3054 Err(klieo_core::BusError::Connection("kv down".into()))
3055 }
3056 async fn put(
3057 &self,
3058 _: &str,
3059 _: &str,
3060 _: Bytes,
3061 ) -> Result<klieo_core::Revision, klieo_core::BusError> {
3062 Err(klieo_core::BusError::Connection("kv down".into()))
3063 }
3064 async fn cas(
3065 &self,
3066 _: &str,
3067 _: &str,
3068 _: Bytes,
3069 _: Option<klieo_core::Revision>,
3070 ) -> Result<klieo_core::Revision, klieo_core::BusError> {
3071 Err(klieo_core::BusError::Connection("kv down".into()))
3072 }
3073 async fn delete(&self, _: &str, _: &str) -> Result<(), klieo_core::BusError> {
3074 Err(klieo_core::BusError::Connection("kv down".into()))
3075 }
3076 async fn lease(
3077 &self,
3078 _: &str,
3079 _: &str,
3080 _: std::time::Duration,
3081 ) -> Result<klieo_core::Lease, klieo_core::BusError> {
3082 Err(klieo_core::BusError::Connection("kv down".into()))
3083 }
3084 async fn keys(&self, _: &str) -> Result<Vec<String>, klieo_core::BusError> {
3085 Err(klieo_core::BusError::Connection("kv down".into()))
3086 }
3087 }
3088
3089 #[tokio::test]
3090 async fn send_message_fails_closed_when_strict_ownership_store_unavailable() {
3091 let bus = klieo_bus_memory::MemoryBus::new();
3095 let dispatcher = A2aDispatcher::builder()
3096 .handler(Arc::new(EchoHandler::default()))
3097 .authenticator(Arc::new(AllowAnonymous))
3098 .pubsub(bus.pubsub.clone())
3099 .with_tenant_binding_strict(Arc::new(UnavailableKv))
3100 .build()
3101 .expect("strict tenant-bound dispatcher builds");
3102
3103 let resp = dispatcher
3104 .dispatch(&named_ctx("alice"), SEND_MESSAGE_PAYLOAD)
3105 .await;
3106 let err = resp
3107 .error
3108 .expect("strict store-down must fail the create closed");
3109 assert_eq!(err.code, codes::SERVER_ERROR);
3110 assert!(resp.result.is_none(), "denied create must carry no task");
3111 }
3112
3113 #[tokio::test]
3114 async fn list_tasks_fails_closed_when_ownership_store_unavailable() {
3115 let handler = Arc::new(EchoHandler::default());
3119 handler
3120 .send_message(&named_ctx("alice"), hi_params())
3121 .await
3122 .expect("seed task on handler");
3123 let bus = klieo_bus_memory::MemoryBus::new();
3124 let dispatcher = A2aDispatcher::builder()
3125 .handler(handler)
3126 .authenticator(Arc::new(AllowAnonymous))
3127 .pubsub(bus.pubsub.clone())
3128 .with_tenant_binding_strict(Arc::new(UnavailableKv))
3129 .build()
3130 .expect("strict tenant-bound dispatcher builds");
3131
3132 let alice = named_ctx("alice");
3133 let list_request = serde_json::to_vec(&serde_json::json!({
3134 "jsonrpc": "2.0", "id": 4, "method": "ListTasks", "params": {},
3135 }))
3136 .expect("encode ListTasks");
3137 let resp = dispatcher.dispatch(&alice, &list_request).await;
3138 let err = resp
3139 .error
3140 .expect("a strict store-down must fail the list closed");
3141 assert_eq!(err.code, codes::SERVER_ERROR);
3142 assert!(
3143 err.message.contains("unavailable") || err.message.contains("list denied"),
3144 "fail-closed message expected, got: {}",
3145 err.message,
3146 );
3147 assert!(resp.result.is_none(), "must not return a partial list");
3148 }
3149
3150 #[tokio::test]
3151 async fn push_notification_config_arms_deny_foreign_principal() {
3152 let dispatcher = tenant_bound_dispatcher();
3153 let alice = named_ctx("alice");
3154 let task_id = create_task(&dispatcher, &alice).await;
3155 let _ownership = claim_for(&dispatcher, &task_id, "alice").await;
3156 let bob = named_ctx("bob");
3157
3158 let requests = [
3163 serde_json::json!({"jsonrpc":"2.0","id":5,"method":"CreateTaskPushNotificationConfig","params":{"taskId":task_id,"url":"https://example.test/hook"}}),
3164 serde_json::json!({"jsonrpc":"2.0","id":6,"method":"GetTaskPushNotificationConfig","params":{"taskId":task_id,"id":"c1"}}),
3165 serde_json::json!({"jsonrpc":"2.0","id":7,"method":"ListTaskPushNotificationConfigs","params":{"taskId":task_id}}),
3166 serde_json::json!({"jsonrpc":"2.0","id":8,"method":"DeleteTaskPushNotificationConfig","params":{"taskId":task_id,"id":"c1"}}),
3167 ];
3168
3169 for request in requests {
3170 let method = request["method"].as_str().expect("method").to_string();
3171 let body = serde_json::to_vec(&request).expect("encode push-config request");
3172
3173 let foreign = dispatcher.dispatch(&bob, &body).await;
3174 let foreign_err = foreign
3175 .error
3176 .unwrap_or_else(|| panic!("{method}: foreign principal must be denied"));
3177 assert_eq!(
3178 foreign_err.code,
3179 codes::SERVER_ERROR,
3180 "{method}: foreign principal must be denied-as-not-found",
3181 );
3182 assert!(
3183 foreign_err.message.contains("task not found"),
3184 "{method}: expected deny-as-not-found, got {}",
3185 foreign_err.message,
3186 );
3187 assert!(foreign.result.is_none(), "{method}: must not leak a body");
3188
3189 let owner = dispatcher.dispatch(&alice, &body).await;
3190 let owner_err = owner
3191 .error
3192 .unwrap_or_else(|| panic!("{method}: EchoHandler declines push-config"));
3193 assert_eq!(
3194 owner_err.code,
3195 codes::METHOD_NOT_FOUND,
3196 "{method}: owner must pass the gate and reach the handler default",
3197 );
3198 }
3199 }
3200
3201 #[tokio::test]
3202 async fn list_tasks_drops_tasks_the_caller_does_not_own() {
3203 let dispatcher = tenant_bound_dispatcher();
3204 let alice = named_ctx("alice");
3205 let bob = named_ctx("bob");
3206
3207 let alice_task = create_task(&dispatcher, &alice).await;
3209 let bob_task = create_task(&dispatcher, &bob).await;
3210 let _alice_owns = claim_for(&dispatcher, &alice_task, "alice").await;
3211 let _bob_owns = claim_for(&dispatcher, &bob_task, "bob").await;
3212
3213 let list_request = serde_json::to_vec(&serde_json::json!({
3214 "jsonrpc": "2.0", "id": 3, "method": "ListTasks", "params": {},
3215 }))
3216 .expect("encode ListTasks");
3217
3218 let resp = dispatcher.dispatch(&bob, &list_request).await;
3219 let ids: Vec<String> = resp
3220 .result
3221 .expect("list result")
3222 .get("tasks")
3223 .and_then(|v| v.as_array())
3224 .expect("tasks array")
3225 .iter()
3226 .filter_map(|t| t.get("id").and_then(|v| v.as_str()).map(str::to_string))
3227 .collect();
3228
3229 assert!(ids.contains(&bob_task), "owner must see own task: {ids:?}");
3230 assert!(
3231 !ids.contains(&alice_task),
3232 "list must not leak a foreign-owned task: {ids:?}",
3233 );
3234 }
3235}