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 event_sink(&self) -> TaskEventSink {
744 TaskEventSink::with_permits(self.pubsub.clone(), self.publish_permits.clone())
745 }
746
747 pub fn pubsub(&self) -> &Arc<dyn klieo_core::Pubsub> {
749 &self.pubsub
750 }
751
752 pub fn publish_permits(&self) -> &Arc<Semaphore> {
759 &self.publish_permits
760 }
761
762 pub fn cancel_registry(&self) -> &klieo_core::CancelRegistry<String> {
770 &self.cancel_registry
771 }
772
773 pub async fn publish_cancel(&self, task_id: &str) -> Result<(), A2aError> {
798 klieo_core::cancel::publish_cancel_signal(&self.pubsub, A2A_CANCEL_SUBJECT_PREFIX, task_id)
799 .await?;
800 Ok(())
801 }
802
803 pub fn with_cancel_subscription(self: &Arc<Self>) {
816 klieo_core::cancel::spawn_wildcard_cancel_subscriber(
817 self.pubsub.clone(),
818 A2A_CANCEL_SUBJECT_PATTERN.to_string(),
819 A2A_CANCEL_SUBJECT_PREFIX.to_string(),
820 self.cancel_registry.clone(),
821 A2A_CANCEL_LOG_TARGET,
822 );
823 }
824
825 #[allow(clippy::too_many_lines)]
832 #[instrument(
833 skip_all,
834 fields(rpc.system = "klieo-a2a", rpc.method = tracing::field::Empty),
835 )]
836 pub async fn dispatch(&self, ctx: &RequestContext, payload: &[u8]) -> JsonRpcResponse {
837 let req: JsonRpcRequest = match serde_json::from_slice(payload) {
838 Ok(r) => r,
839 Err(e) => return A2aError::Json(e).to_json_rpc_error(Value::Null),
840 };
841 tracing::Span::current().record("rpc.method", req.method.as_str());
842 let method = match A2aMethod::from_str(&req.method) {
843 Ok(m) => m,
844 Err(e) => return e.to_json_rpc_error(req.id.clone()),
845 };
846 let id = req.id.clone();
847 if let Some(identity) = ctx.caller.as_ref() {
848 if let Err(e) = self
849 .authenticator
850 .authorize_method(identity, &req.method)
851 .await
852 {
853 warn!(
854 target: "security",
855 error = %e,
856 method = %req.method,
857 "a2a authorize_method rejected",
858 );
859 return A2aError::Unauthorized(e.to_string()).to_json_rpc_error(id);
860 }
861 } else if !self.authenticator.allows_anonymous() {
862 warn!(
863 target: "security",
864 method = %req.method,
865 "a2a anonymous caller rejected (authenticator requires an identity)",
866 );
867 return A2aError::Unauthorized("anonymous caller not permitted".into())
868 .to_json_rpc_error(id);
869 }
870 let params_raw = req.params;
871 let result: Result<Value, A2aError> = match method {
872 A2aMethod::SendMessage => {
873 run_method::<SendMessageParams, _, _>(params_raw, |p| async move {
874 let result = self.handler.send_message(ctx, p).await?;
875 if let SendMessageResult::Task(task) = &result {
882 self.claim_owner_persistent(ctx, &task.id).await?;
883 }
884 Ok(result)
885 })
886 .await
887 }
888 A2aMethod::SendStreamingMessage => {
892 Err(A2aError::MethodNotFound("SendStreamingMessage".into()))
893 }
894 A2aMethod::GetTask => {
895 run_method::<GetTaskParams, _, _>(params_raw, |p| async move {
896 self.enforce_owner(ctx, &p.id).await?;
897 self.handler.get_task(ctx, p).await
898 })
899 .await
900 }
901 A2aMethod::ListTasks => {
902 run_method::<ListTasksParams, _, _>(params_raw, |p| async move {
903 let mut result = self.handler.list_tasks(ctx, p).await?;
904 self.retain_owned_tasks(ctx, &mut result.tasks).await?;
905 Ok(result)
906 })
907 .await
908 }
909 A2aMethod::CancelTask => {
910 run_method::<CancelTaskParams, _, _>(params_raw, |p| async move {
911 self.enforce_owner(ctx, &p.id).await?;
914 self.handler.cancel_task(ctx, p).await
915 })
916 .await
917 }
918 A2aMethod::SubscribeToTask => Err(A2aError::MethodNotFound("SubscribeToTask".into())),
919 A2aMethod::CreateTaskPushNotificationConfig => {
920 run_method::<PushNotificationConfigParams, _, _>(params_raw, |p| async move {
921 self.enforce_owner(ctx, &p.taskId).await?;
922 self.handler
923 .create_task_push_notification_config(ctx, p)
924 .await
925 })
926 .await
927 }
928 A2aMethod::GetTaskPushNotificationConfig => {
929 run_method::<GetPushNotificationConfigParams, _, _>(params_raw, |p| async move {
930 self.enforce_owner(ctx, &p.taskId).await?;
931 self.handler.get_task_push_notification_config(ctx, p).await
932 })
933 .await
934 }
935 A2aMethod::ListTaskPushNotificationConfigs => {
936 run_method::<ListPushNotificationConfigsParams, _, _>(params_raw, |p| async move {
937 self.enforce_owner(ctx, &p.taskId).await?;
938 self.handler
939 .list_task_push_notification_configs(ctx, p)
940 .await
941 })
942 .await
943 }
944 A2aMethod::DeleteTaskPushNotificationConfig => {
945 run_method::<DeletePushNotificationConfigParams, _, _>(params_raw, |p| async move {
946 self.enforce_owner(ctx, &p.taskId).await?;
947 self.handler
948 .delete_task_push_notification_config(ctx, p)
949 .await
950 })
951 .await
952 }
953 A2aMethod::GetExtendedAgentCard => {
954 run_method::<GetExtendedAgentCardParams, _, _>(params_raw, |p| async move {
955 self.handler.get_extended_agent_card(ctx, p).await
956 })
957 .await
958 }
959 };
960 match result {
961 Ok(value) => JsonRpcResponse {
962 jsonrpc: "2.0".into(),
963 id,
964 result: Some(value),
965 error: None,
966 },
967 Err(e) => {
968 log_internal_before_wire_seam(&e, &req.method);
969 e.to_json_rpc_error(id)
970 }
971 }
972 }
973
974 pub async fn handle_request(&self, headers: A2aHeaders, payload: &[u8]) -> JsonRpcResponse {
986 match self.authenticator.authenticate(&headers, payload).await {
987 Ok(identity) => {
988 let ctx = RequestContext::new(headers, Some(identity));
989 self.dispatch(&ctx, payload).await
990 }
991 Err(e) => {
992 warn!(target: "security", error = %e, "a2a auth rejected");
993 let req_id = serde_json::from_slice::<JsonRpcRequest>(payload)
994 .map(|r| r.id)
995 .unwrap_or(Value::Null);
996 JsonRpcResponse {
997 jsonrpc: "2.0".into(),
998 id: req_id,
999 result: None,
1000 error: Some(JsonRpcError {
1001 code: codes::UNAUTHENTICATED,
1002 message: "Unauthenticated".into(),
1003 data: None,
1004 }),
1005 }
1006 }
1007 }
1008 }
1009
1010 pub async fn handle_streaming(
1020 &self,
1021 headers: A2aHeaders,
1022 payload: &[u8],
1023 task_store: &crate::task_store::A2aTaskStore,
1024 cancel: tokio_util::sync::CancellationToken,
1025 last_event_id: Option<u64>,
1026 resume_buffer: Arc<dyn klieo_core::resume::ResumeBuffer>,
1027 ) -> Result<TaskEventStream, A2aError> {
1028 match self.authenticator.authenticate(&headers, payload).await {
1029 Ok(identity) => {
1030 let ctx = RequestContext::new(headers, Some(identity)).with_cancel(cancel);
1031 self.dispatch_streaming(&ctx, payload, task_store, last_event_id, resume_buffer)
1032 .await
1033 }
1034 Err(e) => {
1035 warn!(target: "security", error = %e, "a2a auth rejected (streaming path)");
1036 Err(A2aError::Unauthorized(e.to_string()))
1037 }
1038 }
1039 }
1040
1041 #[instrument(
1063 skip_all,
1064 fields(rpc.system = "klieo-a2a", rpc.method = tracing::field::Empty),
1065 err,
1066 )]
1067 pub async fn dispatch_streaming(
1068 &self,
1069 ctx: &RequestContext,
1070 payload: &[u8],
1071 task_store: &crate::task_store::A2aTaskStore,
1072 last_event_id: Option<u64>,
1073 resume_buffer: Arc<dyn klieo_core::resume::ResumeBuffer>,
1074 ) -> Result<TaskEventStream, A2aError> {
1075 let req: JsonRpcRequest =
1076 serde_json::from_slice(payload).map_err(|e| A2aError::InvalidParams(e.to_string()))?;
1077 tracing::Span::current().record("rpc.method", req.method.as_str());
1078 let method = A2aMethod::from_str(&req.method)
1079 .map_err(|_| A2aError::MethodNotFound(req.method.clone()))?;
1080 if let Some(identity) = ctx.caller.as_ref() {
1081 if let Err(e) = self
1082 .authenticator
1083 .authorize_method(identity, &req.method)
1084 .await
1085 {
1086 warn!(
1087 target: "security",
1088 error = %e,
1089 method = %req.method,
1090 "a2a authorize_method rejected (streaming path)",
1091 );
1092 return Err(A2aError::Unauthorized(e.to_string()));
1093 }
1094 } else if !self.authenticator.allows_anonymous() {
1095 warn!(
1096 target: "security",
1097 method = %req.method,
1098 "a2a anonymous caller rejected (streaming path; authenticator requires an identity)",
1099 );
1100 return Err(A2aError::Unauthorized(
1101 "anonymous caller not permitted".into(),
1102 ));
1103 }
1104 let params_raw = req.params;
1105 match method {
1106 A2aMethod::SendStreamingMessage => {
1107 let params: SendMessageParams = serde_json::from_value(params_raw)
1108 .map_err(|e| A2aError::InvalidParams(e.to_string()))?;
1109 self.dispatch_send_streaming(ctx, params, task_store).await
1112 }
1113 A2aMethod::SubscribeToTask => {
1114 let params: SubscribeToTaskParams = serde_json::from_value(params_raw)
1115 .map_err(|e| A2aError::InvalidParams(e.to_string()))?;
1116 self.dispatch_subscribe_to_task(
1117 ctx,
1118 params,
1119 task_store,
1120 last_event_id,
1121 resume_buffer,
1122 )
1123 .await
1124 }
1125 _ => Err(A2aError::MethodNotFound(req.method)),
1126 }
1127 }
1128
1129 #[instrument(
1130 skip_all,
1131 fields(
1132 rpc.system = "klieo-a2a",
1133 rpc.method = "SendStreamingMessage",
1134 klieo.stream_id = tracing::field::Empty,
1135 ),
1136 err,
1137 )]
1138 async fn dispatch_send_streaming(
1139 &self,
1140 ctx: &RequestContext,
1141 params: SendMessageParams,
1142 task_store: &crate::task_store::A2aTaskStore,
1143 ) -> Result<TaskEventStream, A2aError> {
1144 self.run_streaming_invoke(ctx, params, task_store, None)
1145 .await
1146 }
1147
1148 async fn run_streaming_invoke(
1155 &self,
1156 ctx: &RequestContext,
1157 params: SendMessageParams,
1158 task_store: &crate::task_store::A2aTaskStore,
1159 existing_leader: Option<klieo_core::LeaderHandle>,
1160 ) -> Result<TaskEventStream, A2aError> {
1161 match self
1162 .handler
1163 .send_streaming_message(ctx, params.clone())
1164 .await
1165 {
1166 Ok(stream) => return Ok(stream),
1167 Err(A2aError::MethodNotFound(_)) => {}
1168 Err(other) => return Err(other),
1169 }
1170 let payload_bytes_for_failover = match existing_leader {
1177 Some(_) => None,
1178 None => match serde_json::to_vec(¶ms) {
1179 Ok(v) => Some(Bytes::from(v)),
1180 Err(e) => {
1181 warn!(
1182 target: "a2a.failover",
1183 error = %e,
1184 "send-streaming params serialise for failover failed; \
1185 proceeding without cached payload",
1186 );
1187 None
1188 }
1189 },
1190 };
1191 let principal_for_failover = match existing_leader {
1192 Some(_) => None,
1193 None => ctx
1194 .caller
1195 .as_ref()
1196 .filter(|id| !id.is_anonymous())
1197 .map(|id| id.as_str().to_string()),
1198 };
1199 let result = self.handler.send_message(ctx, params).await?;
1200 let task = match result {
1201 SendMessageResult::Task(t) => t,
1202 SendMessageResult::Message(_) => {
1203 return Ok(Box::pin(futures::stream::empty()));
1206 }
1207 };
1208 let task_id = task.id.clone();
1209 tracing::Span::current().record("klieo.stream_id", task_id.as_str());
1210 let terminal = task.status.is_terminal();
1211 let event_id = task_store.next_event_id(&task_id);
1212 let initial = TaskEvent::new(
1213 task_id.clone(),
1214 task.status,
1215 task.history.last().cloned(),
1216 terminal,
1217 )
1218 .with_event_id(event_id);
1219 if terminal {
1220 return Ok(Box::pin(futures::stream::once(futures::future::ready(
1225 initial,
1226 ))));
1227 }
1228 let initial_stream = futures::stream::once(futures::future::ready(initial));
1229 let tail = self.task_event_stream(task_id.clone()).await?;
1230 let chained = futures::StreamExt::chain(initial_stream, tail);
1231 let leader_handle = match existing_leader {
1237 Some(handle) => Some(handle),
1238 None => {
1239 self.try_claim_leader(&task_id, payload_bytes_for_failover, principal_for_failover)
1240 .await
1241 }
1242 };
1243 let ownership_handle = self.try_claim_ownership(ctx, &task_id).await?;
1244 Ok(Box::pin(LeaderHoldStream {
1245 inner: chained,
1246 _leader: leader_handle,
1247 _ownership: ownership_handle,
1248 }))
1249 }
1250
1251 async fn try_claim_ownership(
1262 &self,
1263 ctx: &RequestContext,
1264 task_id: &str,
1265 ) -> Result<Option<klieo_core::OwnershipHandle>, A2aError> {
1266 let Some(registry) = self.ownership_registry.as_ref() else {
1267 return Ok(None);
1268 };
1269 let Some(caller) = ctx.caller.as_ref() else {
1270 return Ok(None);
1271 };
1272 if caller.is_anonymous() {
1273 return Ok(None);
1274 }
1275 let key = ownership_key(task_id);
1276 let principal = caller.as_str().to_string();
1277 match registry.claim_guarded(key, principal).await {
1278 klieo_core::OwnershipClaim::Claimed(handle) => Ok(Some(handle)),
1279 klieo_core::OwnershipClaim::Proceed => Ok(None),
1280 klieo_core::OwnershipClaim::Unavailable => Err(A2aError::Server(
1281 "ownership store unavailable; invoke denied (strict tenant binding)".into(),
1282 )),
1283 _ => Err(A2aError::Server(
1286 "ownership claim inconclusive; invoke denied".into(),
1287 )),
1288 }
1289 }
1290
1291 async fn claim_owner_persistent(
1299 &self,
1300 ctx: &RequestContext,
1301 task_id: &str,
1302 ) -> Result<(), A2aError> {
1303 let Some(registry) = self.ownership_registry.as_ref() else {
1304 return Ok(());
1305 };
1306 let Some(caller) = ctx.caller.as_ref() else {
1307 return Ok(());
1308 };
1309 if caller.is_anonymous() {
1310 return Ok(());
1311 }
1312 let key = ownership_key(task_id);
1313 match registry
1314 .record_owner(key, caller.as_str().to_string())
1315 .await
1316 {
1317 klieo_core::OwnershipRecord::Recorded | klieo_core::OwnershipRecord::Proceed => Ok(()),
1318 klieo_core::OwnershipRecord::Unavailable => Err(A2aError::Server(
1319 "ownership store unavailable; invoke denied (strict tenant binding)".into(),
1320 )),
1321 _ => Err(A2aError::Server(
1324 "ownership record inconclusive; invoke denied".into(),
1325 )),
1326 }
1327 }
1328
1329 async fn enforce_owner(&self, ctx: &RequestContext, task_id: &str) -> Result<(), A2aError> {
1344 let Some(registry) = self.ownership_registry.as_ref() else {
1345 return Ok(());
1346 };
1347 let Some(caller) = ctx.caller.as_ref() else {
1348 return Ok(());
1349 };
1350 if caller.is_anonymous() {
1351 return Ok(());
1352 }
1353 let key = ownership_key(task_id);
1354 match registry.check_owner(&key, caller.as_str()).await {
1355 klieo_core::OwnershipCheck::Allowed => Ok(()),
1356 klieo_core::OwnershipCheck::Denied => {
1357 warn!(
1361 target: "a2a.tenants",
1362 task_id = %task_id,
1363 principal = %caller.as_str(),
1364 "ownership mismatch on SubscribeToTask; denying as TaskNotFound",
1365 );
1366 Err(A2aError::TaskNotFound(task_id.to_string()))
1367 }
1368 klieo_core::OwnershipCheck::Unavailable => {
1369 warn!(
1370 target: "a2a.tenants",
1371 task_id = %task_id,
1372 principal = %caller.as_str(),
1373 "ownership store unavailable; request denied (strict tenant binding)",
1374 );
1375 Err(A2aError::OwnershipUnavailable)
1376 }
1377 other => {
1378 warn!(
1379 target: "a2a.tenants",
1380 task_id = %task_id,
1381 principal = %caller.as_str(),
1382 verdict = ?other,
1383 "inconclusive ownership verdict; request denied",
1384 );
1385 Err(A2aError::OwnershipUnavailable)
1386 }
1387 }
1388 }
1389
1390 async fn retain_owned_tasks(
1403 &self,
1404 ctx: &RequestContext,
1405 tasks: &mut Vec<Task>,
1406 ) -> Result<(), A2aError> {
1407 let Some(registry) = self.ownership_registry.as_ref() else {
1408 return Ok(());
1409 };
1410 let Some(caller) = ctx.caller.as_ref() else {
1411 return Ok(());
1412 };
1413 if caller.is_anonymous() {
1414 return Ok(());
1415 }
1416 let verdicts = futures::future::join_all(tasks.iter().map(|task| {
1417 let key = ownership_key(&task.id);
1418 async move { registry.check_owner(&key, caller.as_str()).await }
1419 }))
1420 .await;
1421
1422 let mut owned = Vec::with_capacity(tasks.len());
1423 for (task, verdict) in std::mem::take(tasks).into_iter().zip(verdicts) {
1424 match verdict {
1425 klieo_core::OwnershipCheck::Allowed => owned.push(task),
1426 klieo_core::OwnershipCheck::Denied => {}
1427 klieo_core::OwnershipCheck::Unavailable => {
1428 warn!(
1429 target: "a2a.tenants",
1430 principal = %caller.as_str(),
1431 "ownership store unavailable; ListTasks denied (strict tenant binding)",
1432 );
1433 return Err(A2aError::OwnershipUnavailable);
1434 }
1435 other => {
1436 warn!(
1437 target: "a2a.tenants",
1438 principal = %caller.as_str(),
1439 verdict = ?other,
1440 "inconclusive ownership verdict; ListTasks denied",
1441 );
1442 return Err(A2aError::OwnershipUnavailable);
1443 }
1444 }
1445 }
1446 *tasks = owned;
1447 Ok(())
1448 }
1449
1450 async fn try_claim_leader(
1463 &self,
1464 task_id: &str,
1465 payload: Option<Bytes>,
1466 principal: Option<String>,
1467 ) -> Option<klieo_core::LeaderHandle> {
1468 let registry = self.leader_registry.as_ref()?;
1469 let key = format!("{A2A_LEADER_KEY_PREFIX}{task_id}");
1470 match registry
1471 .claim_with_heartbeat(
1472 key,
1473 self.leader_ttl,
1474 self.leader_heartbeat_interval,
1475 payload,
1476 principal,
1477 )
1478 .await
1479 {
1480 Ok(handle) => Some(handle),
1481 Err(e) => {
1482 warn!(
1483 target: "a2a.leader",
1484 task_id = %task_id,
1485 error = %e,
1486 "leader claim failed; degrading to no-claim (orphan detection \
1487 disabled for this stream)",
1488 );
1489 None
1490 }
1491 }
1492 }
1493
1494 async fn write_orphan_terminal_frame(
1509 &self,
1510 task_id: &str,
1511 resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
1512 ) -> Option<()> {
1513 let max_id = max_event_id(resume_buffer, task_id).await?;
1514 let next_id = max_id + 1;
1515 let frame = leader_died_sse_frame_bytes(task_id, next_id);
1516 if let Err(e) = resume_buffer.record(task_id, next_id, frame).await {
1517 warn!(
1518 target: "a2a.leader",
1519 task_id = %task_id,
1520 next_id,
1521 error = %e,
1522 "orphan terminal record failed; skipping orphan write",
1523 );
1524 return None;
1525 }
1526 if let Err(e) = resume_buffer.close(task_id).await {
1527 warn!(
1528 target: "a2a.leader",
1529 task_id = %task_id,
1530 error = %e,
1531 "orphan terminal close failed; resume buffer may retain stale stream",
1532 );
1533 }
1534 tracing::error!(
1535 target: "a2a.leader",
1536 task_id = %task_id,
1537 next_id,
1538 "stream leader died; emitted LEADER_DIED terminal frame at max+1",
1539 );
1540 Some(())
1541 }
1542
1543 #[cfg(not(feature = "test-fixtures"))]
1567 async fn handle_dead_leader_orphan(
1568 &self,
1569 ctx: &RequestContext,
1570 task_id: &str,
1571 task_store: &crate::task_store::A2aTaskStore,
1572 resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
1573 ) -> Result<Option<TaskEventStream>, A2aError> {
1574 self.handle_dead_leader_orphan_impl(ctx, task_id, task_store, resume_buffer)
1575 .await
1576 }
1577
1578 #[cfg(feature = "test-fixtures")]
1582 pub async fn handle_dead_leader_orphan(
1583 &self,
1584 ctx: &RequestContext,
1585 task_id: &str,
1586 task_store: &crate::task_store::A2aTaskStore,
1587 resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
1588 ) -> Result<Option<TaskEventStream>, A2aError> {
1589 self.handle_dead_leader_orphan_impl(ctx, task_id, task_store, resume_buffer)
1590 .await
1591 }
1592
1593 async fn handle_dead_leader_orphan_impl(
1594 &self,
1595 ctx: &RequestContext,
1596 task_id: &str,
1597 task_store: &crate::task_store::A2aTaskStore,
1598 resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
1599 ) -> Result<Option<TaskEventStream>, A2aError> {
1600 let registry = match self.leader_registry.as_ref() {
1601 Some(reg) => reg,
1602 None => return Ok(None),
1603 };
1604 let key = format!("{A2A_LEADER_KEY_PREFIX}{task_id}");
1605 let lookup = registry.lookup_entry_with_revision(&key).await;
1606 let Some((entry, prior_rev)) = lookup_ok_or_log(&key, lookup) else {
1607 return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1610 };
1611 if !self.handler.is_idempotent() {
1612 tracing::debug!(
1613 target: "a2a.failover",
1614 task_id,
1615 "handler not idempotent; emitting terminate frame",
1616 );
1617 return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1618 }
1619 if entry.attempt >= self.max_failover_attempts {
1620 tracing::warn!(
1621 target: "a2a.failover",
1622 task_id,
1623 attempt = entry.attempt,
1624 cap = self.max_failover_attempts,
1625 "failover attempt cap reached; emitting terminate frame",
1626 );
1627 return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1628 }
1629 let Some(payload_bytes) = entry.payload.clone() else {
1630 tracing::debug!(
1631 target: "a2a.failover",
1632 task_id,
1633 "no cached payload on leader entry; emitting terminate frame",
1634 );
1635 return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1636 };
1637 let new_handle = match registry
1638 .claim_with_attempt_cas_and_heartbeat(
1639 key.clone(),
1640 self.leader_ttl,
1641 self.leader_heartbeat_interval,
1642 prior_rev,
1643 &entry,
1644 )
1645 .await
1646 {
1647 Ok(h) => h,
1648 Err(klieo_core::BusError::CasConflict { .. }) => {
1649 tracing::info!(
1650 target: "a2a.failover",
1651 task_id,
1652 "another follower won the CAS race; emitting terminate frame",
1653 );
1654 return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1655 }
1656 Err(e) => {
1657 tracing::warn!(
1658 target: "a2a.failover",
1659 task_id,
1660 error = %e,
1661 "CAS claim failed; emitting terminate frame",
1662 );
1663 return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1664 }
1665 };
1666 self.record_failover_marker(task_id, &entry, &new_handle, resume_buffer)
1667 .await;
1668 let parsed_params: SendMessageParams = match serde_json::from_slice(&payload_bytes) {
1669 Ok(p) => p,
1670 Err(e) => {
1671 tracing::error!(
1672 target: "a2a.failover",
1673 task_id,
1674 error = %e,
1675 "cached payload parse failed; emitting terminate frame",
1676 );
1677 return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1678 }
1679 };
1680 let reinvoke_ctx = self.fabricate_reinvoke_ctx(ctx, entry.principal.as_deref());
1681 let stream = self
1682 .run_streaming_invoke(&reinvoke_ctx, parsed_params, task_store, Some(new_handle))
1683 .await?;
1684 Ok(Some(stream))
1685 }
1686
1687 async fn record_failover_marker(
1692 &self,
1693 task_id: &str,
1694 prior: &klieo_core::LeaderEntry,
1695 new_handle: &klieo_core::LeaderHandle,
1696 resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
1697 ) {
1698 let Some(max) = max_event_id(resume_buffer, task_id).await else {
1699 return;
1702 };
1703 let marker_id = max + 1;
1704 let frame = failover_reinvoke_sse_frame_bytes(
1705 task_id,
1706 marker_id,
1707 prior.attempt + 1,
1708 new_handle.replica_id(),
1709 );
1710 if let Err(e) = resume_buffer.record(task_id, marker_id, frame).await {
1711 warn!(
1712 target: "a2a.failover",
1713 task_id,
1714 marker_id,
1715 error = %e,
1716 "failover-reinvoke marker record failed; continuing without marker",
1717 );
1718 }
1719 }
1720
1721 fn fabricate_reinvoke_ctx(
1728 &self,
1729 ctx: &RequestContext,
1730 principal: Option<&str>,
1731 ) -> RequestContext {
1732 let caller = principal
1733 .map(|p| klieo_auth_common::Identity::new(p.to_string()))
1734 .or_else(|| Some(klieo_auth_common::Identity::anonymous()));
1735 RequestContext::new(ctx.headers.clone(), caller).with_cancel(ctx.cancel.clone())
1736 }
1737
1738 async fn terminate_orphan_a2a(
1744 &self,
1745 task_id: &str,
1746 resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
1747 ) -> Result<Option<TaskEventStream>, A2aError> {
1748 if self
1749 .write_orphan_terminal_frame(task_id, resume_buffer)
1750 .await
1751 .is_some()
1752 {
1753 return Err(A2aError::LeaderDied {
1754 stream_id: format!("{A2A_LEADER_KEY_PREFIX}{task_id}"),
1755 });
1756 }
1757 Ok(None)
1758 }
1759
1760 async fn probe_leader(&self, task_id: &str) -> LeaderProbe {
1768 let Some(registry) = self.leader_registry.as_ref() else {
1769 return LeaderProbe::NoRegistry;
1770 };
1771 let key = format!("{A2A_LEADER_KEY_PREFIX}{task_id}");
1772 match registry.is_alive(&key).await {
1773 Ok(true) => LeaderProbe::Alive,
1774 Ok(false) => LeaderProbe::Dead,
1775 Err(e) => {
1776 warn!(
1777 target: "a2a.leader",
1778 task_id = %task_id,
1779 error = %e,
1780 "is_alive probe failed; treating as alive (fail-open per ADR-020)",
1781 );
1782 LeaderProbe::Alive
1783 }
1784 }
1785 }
1786
1787 #[instrument(
1805 skip_all,
1806 fields(
1807 rpc.system = "klieo-a2a",
1808 rpc.method = "SubscribeToTask",
1809 klieo.stream_id = %params.id,
1810 ),
1811 err,
1812 )]
1813 async fn dispatch_subscribe_to_task(
1814 &self,
1815 ctx: &RequestContext,
1816 params: SubscribeToTaskParams,
1817 task_store: &crate::task_store::A2aTaskStore,
1818 last_event_id: Option<u64>,
1819 resume_buffer: Arc<dyn klieo_core::resume::ResumeBuffer>,
1820 ) -> Result<TaskEventStream, A2aError> {
1821 self.enforce_owner(ctx, ¶ms.id).await?;
1830 if let LeaderProbe::Dead = self.probe_leader(¶ms.id).await {
1838 if let Some(stream) = self
1839 .handle_dead_leader_orphan(ctx, ¶ms.id, task_store, resume_buffer.as_ref())
1840 .await?
1841 {
1842 return Ok(stream);
1843 }
1844 }
1845 if let Some(since) = last_event_id {
1846 match self.handler.subscribe_to_task(ctx, params.clone()).await {
1857 Ok(stream) => return Ok(stream),
1858 Err(A2aError::MethodNotFound(_)) => {}
1859 Err(other) => return Err(other),
1860 }
1861
1862 let task_id_for_tail = params.id.clone();
1865 let tail_stream = self.task_event_stream(task_id_for_tail).await?;
1866
1867 match resume_buffer.replay(¶ms.id, since).await {
1868 Ok(replay_stream) => {
1869 use std::sync::atomic::{AtomicU64, Ordering};
1870 use std::sync::Arc as StdArc;
1871
1872 let max_replayed = StdArc::new(AtomicU64::new(since));
1873 let max_for_replay = max_replayed.clone();
1874 let task_id_for_replay = params.id.clone();
1875 let replay = replay_stream.map(move |(id, bytes)| {
1876 max_for_replay.fetch_max(id, Ordering::SeqCst);
1877 serde_json::from_slice::<TaskEvent>(&bytes)
1878 .unwrap_or_else(|_| TaskEvent::synthetic_corrupt(&task_id_for_replay))
1879 });
1880 let max_for_tail = max_replayed.clone();
1881 let tail = tail_stream.filter(move |ev: &TaskEvent| {
1882 ev.event_id > max_for_tail.load(Ordering::SeqCst)
1883 });
1884 return Ok(Box::pin(futures::StreamExt::chain(replay, tail)));
1885 }
1886 Err(klieo_core::resume::ResumeError::Expired { since_id }) => {
1887 return Err(A2aError::ResumeBufferExpired { since_id });
1888 }
1889 Err(klieo_core::resume::ResumeError::NotFound(_)) => { }
1890 Err(klieo_core::resume::ResumeError::Backend(e)) => {
1891 tracing::warn!(
1892 target: "a2a.resume",
1893 error = %e,
1894 "resume buffer backend error; falling back to snapshot+tail"
1895 );
1896 }
1897 Err(_) => {}
1900 }
1901 }
1902 self.subscribe_snapshot_then_tail(ctx, params, task_store)
1903 .await
1904 }
1905
1906 #[instrument(
1913 skip_all,
1914 fields(klieo.stream_id = %params.id),
1915 level = "debug",
1916 err,
1917 )]
1918 async fn subscribe_snapshot_then_tail(
1919 &self,
1920 ctx: &RequestContext,
1921 params: SubscribeToTaskParams,
1922 task_store: &crate::task_store::A2aTaskStore,
1923 ) -> Result<TaskEventStream, A2aError> {
1924 match self.handler.subscribe_to_task(ctx, params.clone()).await {
1925 Ok(stream) => return Ok(stream),
1926 Err(A2aError::MethodNotFound(_)) => {}
1927 Err(other) => return Err(other),
1928 }
1929 let task = task_store
1930 .get(¶ms.id)
1931 .await?
1932 .ok_or_else(|| A2aError::TaskNotFound(params.id.clone()))?;
1933 let terminal = task.status.is_terminal();
1934 let event_id = task_store.next_event_id(&task.id);
1935 let initial = TaskEvent::new(
1936 task.id.clone(),
1937 task.status,
1938 task.history.last().cloned(),
1939 terminal,
1940 )
1941 .with_event_id(event_id);
1942 self.task_event_stream_with_initial(task.id, initial, terminal)
1943 .await
1944 }
1945
1946 #[instrument(
1953 skip_all,
1954 fields(klieo.stream_id = %task_id),
1955 level = "debug",
1956 err,
1957 )]
1958 async fn task_event_stream(&self, task_id: String) -> Result<TaskEventStream, A2aError> {
1959 klieo_core::validate_subject_token(&task_id)?;
1960 let subject = format!("klieo.a2a.task.{task_id}");
1961 let durable = DurableName::new(format!("klieo-eph-{}", uuid::Uuid::new_v4()));
1962 let msg_stream = self.pubsub.subscribe(&subject, durable).await?;
1963 let stream = futures::stream::unfold((msg_stream, false), move |(mut ms, done)| {
1964 let task_id = task_id.clone();
1965 async move {
1966 if done {
1967 return None;
1968 }
1969 loop {
1970 match ms.next().await {
1971 None => return None,
1972 Some(Err(e)) => {
1973 warn!(
1974 target: "a2a",
1975 task_id = %task_id,
1976 error = %e,
1977 "task event stream bus error; skipping",
1978 );
1979 continue;
1980 }
1981 Some(Ok(msg)) => {
1982 if let Err(ack_err) = msg.ack.ack().await {
1984 tracing::warn!(target: "a2a", error = %ack_err, "ack failed; message may redeliver");
1985 }
1986 let parent_cx = klieo_core::extract_traceparent(&msg.headers);
1991 let decode_span = tracing::info_span!(
1992 "task_event_decode",
1993 messaging.system = "klieo-bus",
1994 messaging.destination = %format!("klieo.a2a.task.{task_id}"),
1995 messaging.operation = "receive",
1996 klieo.stream_id = %task_id,
1997 );
1998 decode_span.set_parent(parent_cx);
1999 let _enter = decode_span.enter();
2000 match serde_json::from_slice::<TaskEvent>(&msg.payload) {
2001 Err(e) => {
2002 warn!(
2003 target: "a2a",
2004 task_id = %task_id,
2005 error = %e,
2006 "task event decode error; skipping",
2007 );
2008 continue;
2009 }
2010 Ok(ev) => {
2011 let terminal = ev.final_event;
2012 return Some((ev, (ms, terminal)));
2013 }
2014 }
2015 }
2016 }
2017 }
2018 }
2019 });
2020 Ok(Box::pin(stream))
2021 }
2022
2023 async fn task_event_stream_with_initial(
2024 &self,
2025 task_id: String,
2026 initial: TaskEvent,
2027 initial_terminal: bool,
2028 ) -> Result<TaskEventStream, A2aError> {
2029 use futures::StreamExt as FutExt;
2030
2031 let initial_stream = futures::stream::once(futures::future::ready(initial));
2032 if initial_terminal {
2033 return Ok(Box::pin(initial_stream));
2036 }
2037 let tail = self.task_event_stream(task_id).await?;
2038 Ok(Box::pin(FutExt::chain(initial_stream, tail)))
2039 }
2040}
2041
2042pub struct A2aServer {
2044 app_prefix: String,
2045 agent_id: String,
2046 dispatcher: Arc<A2aDispatcher>,
2047 pubsub: Arc<dyn Pubsub>,
2048}
2049
2050impl A2aServer {
2051 pub fn new(
2061 app_prefix: String,
2062 agent_id: String,
2063 handler: Arc<dyn A2aHandler>,
2064 authenticator: Arc<dyn Authenticator>,
2065 pubsub: Arc<dyn Pubsub>,
2066 ) -> Self {
2067 let dispatcher = Arc::new(A2aDispatcher::new(handler, authenticator, pubsub.clone()));
2068 Self {
2069 app_prefix,
2070 agent_id,
2071 dispatcher,
2072 pubsub,
2073 }
2074 }
2075
2076 pub fn dispatcher(&self) -> &Arc<A2aDispatcher> {
2079 &self.dispatcher
2080 }
2081
2082 pub fn subject(&self) -> String {
2084 format!("{}.a2a.{}.rpc", self.app_prefix, self.agent_id)
2085 }
2086
2087 pub async fn run(self) -> Result<(), A2aError> {
2089 let subject = self.subject();
2090 let durable = DurableName::new(format!("a2a-server-{}", self.agent_id));
2091 let mut stream = self.pubsub.subscribe(&subject, durable).await?;
2092 while let Some(item) = stream.next().await {
2093 match item {
2094 Ok(msg) => {
2095 self.handle_one(msg).await;
2096 }
2097 Err(e) => {
2098 error!(error = %e, "a2a subscribe stream error; exiting loop");
2099 return Err(A2aError::Bus(e));
2100 }
2101 }
2102 }
2103 Ok(())
2104 }
2105
2106 async fn handle_one(&self, msg: Msg) {
2107 let reply_to = match msg.headers.get("Reply-To") {
2108 Some(s) => s.clone(),
2109 None => {
2110 warn!("a2a request missing Reply-To header; dropping");
2111 if let Err(ack_err) = msg.ack.ack().await {
2112 tracing::warn!(target: "a2a", error = %ack_err, "ack failed; message may redeliver");
2113 }
2114 return;
2115 }
2116 };
2117 let headers = A2aHeaders::decode_from(&msg.headers);
2118 let response = self.dispatcher.handle_request(headers, &msg.payload).await;
2119 let bytes = match serde_json::to_vec(&response) {
2120 Ok(b) => Bytes::from(b),
2121 Err(e) => {
2122 error!(error = %e, "encode response");
2123 if let Err(ack_err) = msg.ack.ack().await {
2124 tracing::warn!(target: "a2a", error = %ack_err, "ack failed; message may redeliver");
2125 }
2126 return;
2127 }
2128 };
2129 if let Err(e) = self.pubsub.publish(&reply_to, bytes, Headers::new()).await {
2130 error!(error = %e, "publish reply");
2131 }
2132 if let Err(ack_err) = msg.ack.ack().await {
2133 tracing::warn!(target: "a2a", error = %ack_err, "ack failed; message may redeliver");
2134 }
2135 }
2136}
2137
2138async fn max_event_id(
2143 resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
2144 stream_id: &str,
2145) -> Option<u64> {
2146 let mut replay = match resume_buffer.replay(stream_id, 0).await {
2147 Ok(stream) => stream,
2148 Err(klieo_core::resume::ResumeError::NotFound(_)) => return None,
2149 Err(e) => {
2150 warn!(
2151 target: "a2a.leader",
2152 stream_id,
2153 error = %e,
2154 "max_event_id replay failed; skipping orphan terminal write",
2155 );
2156 return None;
2157 }
2158 };
2159 let mut highest: Option<u64> = None;
2160 while let Some((id, _)) = tokio_stream::StreamExt::next(&mut replay).await {
2161 highest = Some(match highest {
2162 Some(current) => current.max(id),
2163 None => id,
2164 });
2165 }
2166 highest
2167}
2168
2169fn lookup_ok_or_log(
2173 key: &str,
2174 lookup: Result<Option<(klieo_core::LeaderEntry, klieo_core::Revision)>, klieo_core::BusError>,
2175) -> Option<(klieo_core::LeaderEntry, klieo_core::Revision)> {
2176 match lookup {
2177 Ok(Some(pair)) => Some(pair),
2178 Ok(None) => None,
2179 Err(e) => {
2180 warn!(
2181 target: "a2a.failover",
2182 key,
2183 error = %e,
2184 "leader entry lookup_with_revision failed; falling back to terminate",
2185 );
2186 None
2187 }
2188 }
2189}
2190
2191fn failover_reinvoke_sse_frame_bytes(
2199 task_id: &str,
2200 event_id: u64,
2201 attempt: u32,
2202 new_replica_id: &str,
2203) -> bytes::Bytes {
2204 let payload = serde_json::json!({
2205 "jsonrpc": "2.0",
2206 "id": serde_json::Value::Null,
2207 "event": "failover-reinvoke",
2208 "data": {
2209 "stream_id": format!("{A2A_LEADER_KEY_PREFIX}{task_id}"),
2210 "attempt": attempt,
2211 "by_replica": new_replica_id,
2212 },
2213 "event_id": event_id,
2214 });
2215 bytes::Bytes::from(serde_json::to_vec(&payload).unwrap_or_default())
2216}
2217
2218fn leader_died_sse_frame_bytes(task_id: &str, event_id: u64) -> bytes::Bytes {
2223 let payload = serde_json::json!({
2224 "jsonrpc": "2.0",
2225 "id": serde_json::Value::Null,
2226 "error": {
2227 "code": codes::LEADER_DIED,
2228 "message": "stream leader died",
2229 "data": { "stream_id": format!("{A2A_LEADER_KEY_PREFIX}{task_id}") }
2230 },
2231 "event_id": event_id,
2232 });
2233 bytes::Bytes::from(serde_json::to_vec(&payload).unwrap_or_default())
2234}
2235
2236pub(crate) fn log_internal_before_wire_seam(err: &A2aError, method: &str) {
2246 let internal = matches!(
2247 err,
2248 A2aError::Bus(_)
2249 | A2aError::Server(_)
2250 | A2aError::Misconfigured(_)
2251 | A2aError::Internal { .. }
2252 );
2253 if internal {
2254 error!(
2255 target: "a2a.wire",
2256 method = %method,
2257 error = %err,
2258 source = ?std::error::Error::source(err),
2259 "internal error mapped to JSON-RPC wire envelope",
2260 );
2261 } else {
2262 warn!(
2263 target: "a2a.wire",
2264 method = %method,
2265 error = %err,
2266 "client-class error mapped to JSON-RPC wire envelope",
2267 );
2268 }
2269}
2270
2271async fn run_method<P, T, Fut>(raw: Value, run: impl FnOnce(P) -> Fut) -> Result<Value, A2aError>
2274where
2275 P: DeserializeOwned,
2276 T: Serialize,
2277 Fut: std::future::Future<Output = Result<T, A2aError>>,
2278{
2279 let params: P =
2280 serde_json::from_value(raw).map_err(|e| A2aError::InvalidParams(e.to_string()))?;
2281 let typed = run(params).await?;
2282 serde_json::to_value(typed).map_err(A2aError::from)
2283}
2284
2285#[cfg(test)]
2286mod tests {
2287 use super::*;
2288 use crate::envelope::A2aHeaders;
2289 use crate::handler::EchoHandler;
2290 use crate::task_store::A2aTaskStore;
2291 use crate::types::{Task, TaskStatus};
2292 use klieo_auth_common::{AllowAnonymous, Identity};
2293 use klieo_bus_memory::MemoryBus;
2294
2295 fn anon_ctx() -> RequestContext {
2296 RequestContext::new(
2297 A2aHeaders::decode_from(&klieo_core::Headers::new()),
2298 Some(Identity::anonymous()),
2299 )
2300 }
2301
2302 fn make_store_with_dispatcher(dispatcher: &A2aDispatcher) -> A2aTaskStore {
2303 A2aTaskStore::new(
2304 Arc::new(MemoryBus::new()).kv.clone(),
2305 crate::task_store::DEFAULT_BUCKET.into(),
2306 )
2307 .with_event_sink(dispatcher.event_sink())
2308 }
2309
2310 fn make_task(id: &str, status: TaskStatus) -> Task {
2311 Task {
2312 id: id.into(),
2313 contextId: "ctx-1".into(),
2314 status,
2315 artifacts: vec![],
2316 history: vec![],
2317 metadata: None,
2318 }
2319 }
2320
2321 fn noop_resume_buffer() -> Arc<dyn klieo_core::resume::ResumeBuffer> {
2322 Arc::new(klieo_core::resume::NoopResumeBuffer)
2323 }
2324
2325 #[tokio::test]
2326 async fn local_wires_anonymous_auth_and_in_process_pubsub_and_dispatches() {
2327 let dispatcher = A2aDispatcher::local(Arc::new(EchoHandler::default()));
2328 let ctx = anon_ctx();
2329 let payload = br#"{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"messageId":"m1","role":"user","parts":[{"type":"text","content":"hi"}],"extensions":[],"referenceTaskIds":[]}}}"#;
2330 let resp = dispatcher.dispatch(&ctx, payload).await;
2331 assert_eq!(resp.id, serde_json::json!(1));
2332 assert!(resp.result.is_some());
2333 assert!(resp.error.is_none());
2334 }
2335
2336 #[tokio::test]
2337 async fn dispatcher_routes_send_message_to_handler() {
2338 let dispatcher = A2aDispatcher::with_in_process_pubsub(
2339 Arc::new(EchoHandler::default()),
2340 Arc::new(AllowAnonymous),
2341 );
2342 let ctx = anon_ctx();
2343 let payload = br#"{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"messageId":"m1","role":"user","parts":[{"type":"text","content":"hi"}],"extensions":[],"referenceTaskIds":[]}}}"#;
2344 let resp = dispatcher.dispatch(&ctx, payload).await;
2345 assert_eq!(resp.id, serde_json::json!(1));
2346 assert!(resp.result.is_some());
2347 assert!(resp.error.is_none());
2348 }
2349
2350 fn named_ctx(principal: &str) -> RequestContext {
2351 RequestContext::new(
2352 A2aHeaders::decode_from(&klieo_core::Headers::new()),
2353 Some(Identity::new(principal)),
2354 )
2355 }
2356
2357 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":[]}}}"#;
2358
2359 #[tokio::test]
2360 async fn non_streaming_send_message_claims_ownership_so_foreign_get_task_is_denied() {
2361 let kv = Arc::new(MemoryBus::new()).kv.clone();
2362 let dispatcher = A2aDispatcher::builder()
2363 .handler(Arc::new(EchoHandler::default()))
2364 .authenticator(Arc::new(AllowAnonymous))
2365 .with_in_process_pubsub()
2366 .with_tenant_binding(kv)
2367 .build()
2368 .unwrap();
2369
2370 let created = dispatcher.dispatch(&named_ctx("alice"), SEND_HI).await;
2372 let task_id = created.result.expect("SendMessage returns a task")["id"]
2373 .as_str()
2374 .expect("task id is a string")
2375 .to_string();
2376 let get = format!(
2377 r#"{{"jsonrpc":"2.0","id":2,"method":"GetTask","params":{{"id":"{task_id}"}}}}"#
2378 );
2379
2380 let foreign = dispatcher.dispatch(&named_ctx("bob"), get.as_bytes()).await;
2383 assert!(
2384 foreign.error.is_some(),
2385 "foreign GetTask must be denied, got result {:?}",
2386 foreign.result
2387 );
2388
2389 let owner = dispatcher
2391 .dispatch(&named_ctx("alice"), get.as_bytes())
2392 .await;
2393 assert!(
2394 owner.error.is_none(),
2395 "owner GetTask must succeed, got error {:?}",
2396 owner.error
2397 );
2398 assert!(owner.result.is_some());
2399 }
2400
2401 #[tokio::test]
2402 async fn dispatcher_streaming_subscribe_to_task_replays_current_state() {
2403 let dispatcher = A2aDispatcher::with_in_process_pubsub(
2404 Arc::new(EchoHandler::default()),
2405 Arc::new(AllowAnonymous),
2406 );
2407 let store = make_store_with_dispatcher(&dispatcher);
2408
2409 let task = make_task("t-1", TaskStatus::Working);
2410 store.put(&task).await.unwrap();
2411
2412 let payload =
2413 br#"{"jsonrpc":"2.0","id":1,"method":"SubscribeToTask","params":{"id":"t-1"}}"#;
2414 let ctx = anon_ctx();
2415 let mut stream = dispatcher
2416 .dispatch_streaming(&ctx, payload, &store, None, noop_resume_buffer())
2417 .await
2418 .unwrap();
2419
2420 let first = stream.next().await.expect("stream produced no first event");
2421 assert_eq!(first.task_id, "t-1");
2422 assert!(matches!(first.status, TaskStatus::Working));
2423 assert!(!first.final_event, "Working is not terminal");
2424 }
2425
2426 #[tokio::test]
2427 async fn dispatcher_streaming_rejects_unknown_task() {
2428 let dispatcher = A2aDispatcher::with_in_process_pubsub(
2429 Arc::new(EchoHandler::default()),
2430 Arc::new(AllowAnonymous),
2431 );
2432 let store = make_store_with_dispatcher(&dispatcher);
2433
2434 let payload =
2435 br#"{"jsonrpc":"2.0","id":1,"method":"SubscribeToTask","params":{"id":"nope"}}"#;
2436 let ctx = anon_ctx();
2437 let result = dispatcher
2438 .dispatch_streaming(&ctx, payload, &store, None, noop_resume_buffer())
2439 .await;
2440 assert!(matches!(result, Err(A2aError::TaskNotFound(_))));
2441 }
2442
2443 struct NoAnonAuthn;
2444
2445 #[async_trait::async_trait]
2446 impl Authenticator for NoAnonAuthn {
2447 async fn authenticate(
2448 &self,
2449 _headers: &dyn klieo_auth_common::Headers,
2450 _payload: &[u8],
2451 ) -> Result<Identity, klieo_auth_common::AuthError> {
2452 Ok(Identity::new("svc"))
2453 }
2454 }
2455
2456 #[tokio::test]
2457 async fn dispatcher_streaming_rejects_anonymous_under_named_authenticator() {
2458 let dispatcher = A2aDispatcher::with_in_process_pubsub(
2461 Arc::new(EchoHandler::default()),
2462 Arc::new(NoAnonAuthn),
2463 );
2464 let store = make_store_with_dispatcher(&dispatcher);
2465 let payload = br#"{"jsonrpc":"2.0","id":1,"method":"SendStreamingMessage","params":{"message":{"messageId":"m1","role":"user","parts":[{"type":"text","content":"hi"}],"extensions":[],"referenceTaskIds":[]}}}"#;
2466 let ctx = RequestContext::new(A2aHeaders::decode_from(&klieo_core::Headers::new()), None);
2467 let result = dispatcher
2468 .dispatch_streaming(&ctx, payload, &store, None, noop_resume_buffer())
2469 .await;
2470 assert!(matches!(result, Err(A2aError::Unauthorized(_))));
2471 }
2472
2473 #[tokio::test]
2474 async fn dispatcher_streaming_subscribe_terminal_task_closes_stream() {
2475 let dispatcher = A2aDispatcher::with_in_process_pubsub(
2476 Arc::new(EchoHandler::default()),
2477 Arc::new(AllowAnonymous),
2478 );
2479 let store = make_store_with_dispatcher(&dispatcher);
2480
2481 let task = make_task("t-done", TaskStatus::Completed);
2482 store.put(&task).await.unwrap();
2483
2484 let payload =
2485 br#"{"jsonrpc":"2.0","id":1,"method":"SubscribeToTask","params":{"id":"t-done"}}"#;
2486 let ctx = anon_ctx();
2487 let mut stream = dispatcher
2488 .dispatch_streaming(&ctx, payload, &store, None, noop_resume_buffer())
2489 .await
2490 .unwrap();
2491
2492 let first = stream.next().await.expect("expected replay event");
2493 assert_eq!(first.task_id, "t-done");
2494 assert!(first.final_event, "Completed must set final_event=true");
2495 assert!(stream.next().await.is_none(), "stream must be exhausted");
2497 }
2498
2499 #[tokio::test]
2500 async fn dispatcher_streaming_send_streaming_message_returns_stream() {
2501 let dispatcher = A2aDispatcher::with_in_process_pubsub(
2502 Arc::new(EchoHandler::default()),
2503 Arc::new(AllowAnonymous),
2504 );
2505 let store = make_store_with_dispatcher(&dispatcher);
2506
2507 let payload = br#"{"jsonrpc":"2.0","id":1,"method":"SendStreamingMessage","params":{"message":{"messageId":"m1","role":"user","parts":[{"type":"text","content":"hi"}],"extensions":[],"referenceTaskIds":[]}}}"#;
2508 let ctx = anon_ctx();
2509 let result = dispatcher
2510 .dispatch_streaming(&ctx, payload, &store, None, noop_resume_buffer())
2511 .await;
2512 assert!(
2513 result.is_ok(),
2514 "dispatch_streaming should return Ok for SendStreamingMessage"
2515 );
2516 }
2517
2518 #[tokio::test]
2519 async fn handle_streaming_threads_cancel_token_into_request_context() {
2520 use crate::auth::RequestContext;
2521 use crate::error::A2aError;
2522 use crate::handler::A2aHandler;
2523 use crate::server::TaskEventStream;
2524 use crate::types::SendMessageParams;
2525 use klieo_auth_common::{AllowAnonymous, Authenticator};
2526 use std::sync::{Arc, Mutex};
2527 use tokio_util::sync::CancellationToken;
2528
2529 struct CancelSpy {
2530 observed: Mutex<Option<CancellationToken>>,
2531 }
2532 #[async_trait::async_trait]
2533 impl A2aHandler for CancelSpy {
2534 async fn send_streaming_message(
2535 &self,
2536 ctx: &RequestContext,
2537 _: SendMessageParams,
2538 ) -> Result<TaskEventStream, A2aError> {
2539 *self.observed.lock().unwrap() = Some(ctx.cancel.clone());
2540 Ok(Box::pin(futures::stream::empty()))
2541 }
2542 }
2543
2544 let spy = Arc::new(CancelSpy {
2545 observed: Mutex::new(None),
2546 });
2547 let dispatcher = A2aDispatcher::with_in_process_pubsub(
2548 spy.clone() as Arc<dyn A2aHandler>,
2549 Arc::new(AllowAnonymous) as Arc<dyn Authenticator>,
2550 );
2551 let store = A2aTaskStore::new(
2552 Arc::new(klieo_bus_memory::MemoryBus::new()).kv.clone(),
2553 crate::task_store::DEFAULT_BUCKET.into(),
2554 )
2555 .with_event_sink(dispatcher.event_sink());
2556
2557 let token = CancellationToken::new();
2558 let body = serde_json::to_vec(&serde_json::json!({
2559 "jsonrpc": "2.0",
2560 "id": 1,
2561 "method": "SendStreamingMessage",
2562 "params": {
2563 "message": {
2564 "messageId": "m-spy",
2565 "role": "user",
2566 "parts": [],
2567 "extensions": [],
2568 "referenceTaskIds": []
2569 }
2570 }
2571 }))
2572 .unwrap();
2573
2574 let _ = dispatcher
2575 .handle_streaming(
2576 A2aHeaders::decode_from(&klieo_core::Headers::new()),
2577 &body,
2578 &store,
2579 token.clone(),
2580 None,
2581 noop_resume_buffer(),
2582 )
2583 .await
2584 .unwrap();
2585
2586 token.cancel();
2587 let observed = spy.observed.lock().unwrap().clone().unwrap();
2588 assert!(
2589 observed.is_cancelled(),
2590 "cancel must propagate from handle_streaming arg"
2591 );
2592 }
2593
2594 #[tokio::test]
2595 async fn task_event_sink_publishes_to_per_task_subject() {
2596 let bus = klieo_bus_memory::MemoryBus::new();
2597 let pubsub: std::sync::Arc<dyn klieo_core::Pubsub> = bus.pubsub.clone();
2598 let sink = TaskEventSink::new(pubsub.clone());
2599
2600 let durable = klieo_core::DurableName::new("test-eph");
2601 let mut stream = pubsub
2602 .subscribe("klieo.a2a.task.t-1", durable)
2603 .await
2604 .unwrap();
2605
2606 let event = TaskEvent::new(
2607 "t-1".to_string(),
2608 crate::types::TaskStatus::Submitted,
2609 None,
2610 false,
2611 )
2612 .with_event_id(1);
2613
2614 sink.send(event.clone()).await.unwrap();
2615
2616 use tokio_stream::StreamExt as _;
2617 let msg = tokio::time::timeout(std::time::Duration::from_millis(500), stream.next())
2618 .await
2619 .expect("timeout")
2620 .expect("stream ended")
2621 .expect("subscribe err");
2622 let decoded: TaskEvent = serde_json::from_slice(&msg.payload).unwrap();
2623 assert_eq!(decoded.task_id, "t-1");
2624 assert_eq!(decoded.event_id, 1);
2625 msg.ack.ack().await.unwrap();
2626 }
2627}
2628
2629#[cfg(test)]
2630mod profile_tests {
2631 use super::*;
2632 use crate::handler::EchoHandler;
2633 use klieo_auth_common::{AllowAnonymous, AuthError, Headers, Identity};
2634 use klieo_core::DeploymentProfile;
2635
2636 struct NamedAuthn;
2637
2638 #[async_trait::async_trait]
2639 impl Authenticator for NamedAuthn {
2640 async fn authenticate(
2641 &self,
2642 _headers: &dyn Headers,
2643 _payload: &[u8],
2644 ) -> Result<Identity, AuthError> {
2645 Ok(Identity::new("alice"))
2646 }
2647 }
2648
2649 fn builder_with(auth: Arc<dyn Authenticator>) -> A2aDispatcherBuilder {
2650 let bus = klieo_bus_memory::MemoryBus::new();
2651 A2aDispatcher::builder()
2652 .handler(Arc::new(EchoHandler::default()))
2653 .authenticator(auth)
2654 .pubsub(bus.pubsub.clone())
2655 }
2656
2657 #[test]
2658 fn regulated_without_tenant_kv_fails_closed() {
2659 let err = builder_with(Arc::new(NamedAuthn))
2660 .profile(DeploymentProfile::RegulatedMultiTenant)
2661 .build()
2662 .err()
2663 .expect("expected RegulatedProfile error");
2664 assert!(matches!(
2665 err,
2666 A2aBuilderError::RegulatedProfile(klieo_core::ProfileViolation::MissingTenantKv)
2667 ));
2668 }
2669
2670 #[test]
2671 fn regulated_with_anonymous_auth_fails_closed() {
2672 let bus = klieo_bus_memory::MemoryBus::new();
2673 let err = builder_with(Arc::new(AllowAnonymous))
2674 .with_tenant_binding(bus.kv.clone())
2675 .profile(DeploymentProfile::RegulatedMultiTenant)
2676 .build()
2677 .err()
2678 .expect("expected RegulatedProfile error");
2679 assert!(matches!(
2680 err,
2681 A2aBuilderError::RegulatedProfile(klieo_core::ProfileViolation::AnonymousAuth)
2682 ));
2683 }
2684
2685 #[test]
2686 fn regulated_forces_strict_over_lenient_binding() {
2687 let bus = klieo_bus_memory::MemoryBus::new();
2688 let dispatcher = builder_with(Arc::new(NamedAuthn))
2689 .with_tenant_binding(bus.kv.clone())
2690 .profile(DeploymentProfile::RegulatedMultiTenant)
2691 .build()
2692 .expect("regulated build with named auth + kv must succeed");
2693 assert_eq!(
2694 dispatcher
2695 .ownership_registry
2696 .as_ref()
2697 .map(|r| r.is_strict()),
2698 Some(true),
2699 "regulated profile must force a strict registry even over lenient binding"
2700 );
2701 }
2702
2703 #[test]
2704 fn unprofiled_keeps_lenient_binding() {
2705 let bus = klieo_bus_memory::MemoryBus::new();
2706 let dispatcher = builder_with(Arc::new(NamedAuthn))
2707 .with_tenant_binding(bus.kv.clone())
2708 .build()
2709 .expect("unprofiled build ok");
2710 assert_eq!(
2711 dispatcher
2712 .ownership_registry
2713 .as_ref()
2714 .map(|r| r.is_strict()),
2715 Some(false)
2716 );
2717 }
2718
2719 fn tenant_bound_dispatcher() -> A2aDispatcher {
2723 let bus = klieo_bus_memory::MemoryBus::new();
2724 A2aDispatcher::builder()
2725 .handler(Arc::new(EchoHandler::default()))
2726 .authenticator(Arc::new(AllowAnonymous))
2727 .pubsub(bus.pubsub.clone())
2728 .with_tenant_binding(bus.kv.clone())
2729 .build()
2730 .expect("tenant-bound dispatcher builds")
2731 }
2732
2733 fn named_ctx(principal: &str) -> RequestContext {
2734 RequestContext::new(
2735 A2aHeaders::decode_from(&klieo_core::Headers::new()),
2736 Some(Identity::new(principal)),
2737 )
2738 }
2739
2740 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":[]}}}"#;
2741
2742 #[tokio::test]
2743 async fn anonymous_caller_rejected_when_authenticator_requires_identity() {
2744 let dispatcher = builder_with(Arc::new(NamedAuthn)).build().expect("build");
2745 let ctx = RequestContext::new(A2aHeaders::decode_from(&klieo_core::Headers::new()), None);
2746 let resp = dispatcher.dispatch(&ctx, SEND_MESSAGE_PAYLOAD).await;
2747 let err = resp.error.expect("anonymous caller must be rejected");
2748 assert_eq!(err.code, codes::UNAUTHENTICATED);
2749 assert!(
2750 resp.result.is_none(),
2751 "rejected request must carry no result"
2752 );
2753 }
2754
2755 fn hi_params() -> crate::types::SendMessageParams {
2758 serde_json::from_value(serde_json::json!({
2759 "message": {
2760 "messageId": "m1",
2761 "role": "user",
2762 "parts": [{"type": "text", "content": "hi"}],
2763 "extensions": [],
2764 "referenceTaskIds": []
2765 }
2766 }))
2767 .expect("valid SendMessageParams")
2768 }
2769
2770 async fn create_task(dispatcher: &A2aDispatcher, ctx: &RequestContext) -> String {
2771 let resp = dispatcher.dispatch(ctx, SEND_MESSAGE_PAYLOAD).await;
2772 resp.result
2773 .expect("SendMessage result")
2774 .get("id")
2775 .and_then(|v| v.as_str())
2776 .expect("created task carries an id")
2777 .to_string()
2778 }
2779
2780 #[tokio::test]
2781 async fn anonymous_send_message_records_no_ownership() {
2782 let dispatcher = tenant_bound_dispatcher();
2786 let anon = RequestContext::new(
2787 A2aHeaders::decode_from(&klieo_core::Headers::new()),
2788 Some(Identity::anonymous()),
2789 );
2790 let created = dispatcher.dispatch(&anon, SEND_MESSAGE_PAYLOAD).await;
2791 let task_id = created.result.expect("SendMessage returns a task")["id"]
2792 .as_str()
2793 .expect("task id is a string")
2794 .to_string();
2795 let owner = dispatcher
2796 .ownership_registry()
2797 .expect("registry wired")
2798 .lookup(&format!("{A2A_OWNERSHIP_KEY_PREFIX}{task_id}"))
2799 .await
2800 .expect("lookup ok");
2801 assert!(
2802 owner.is_none(),
2803 "anonymous SendMessage must not write an ownership entry, got {owner:?}"
2804 );
2805 }
2806
2807 #[must_use]
2810 async fn claim_for(
2811 dispatcher: &A2aDispatcher,
2812 task_id: &str,
2813 principal: &str,
2814 ) -> klieo_core::OwnershipHandle {
2815 dispatcher
2816 .ownership_registry()
2817 .expect("ownership registry wired")
2818 .claim(
2819 format!("{A2A_OWNERSHIP_KEY_PREFIX}{task_id}"),
2820 principal.into(),
2821 )
2822 .await
2823 .expect("claim ownership")
2824 }
2825
2826 fn by_id_request(method: &str, task_id: &str) -> Vec<u8> {
2827 serde_json::to_vec(&serde_json::json!({
2828 "jsonrpc": "2.0",
2829 "id": 2,
2830 "method": method,
2831 "params": { "id": task_id },
2832 }))
2833 .expect("encode by-id request")
2834 }
2835
2836 #[tokio::test]
2837 async fn get_task_owner_reads_but_foreign_principal_denied_as_not_found() {
2838 let dispatcher = tenant_bound_dispatcher();
2839 let alice = named_ctx("alice");
2840 let task_id = create_task(&dispatcher, &alice).await;
2841 let _ownership = claim_for(&dispatcher, &task_id, "alice").await;
2842
2843 let request = by_id_request("GetTask", &task_id);
2844
2845 let owner = dispatcher.dispatch(&alice, &request).await;
2846 assert!(owner.error.is_none(), "owner must read own task: {owner:?}");
2847 assert_eq!(
2848 owner
2849 .result
2850 .expect("owner result")
2851 .get("id")
2852 .and_then(|v| v.as_str()),
2853 Some(task_id.as_str()),
2854 );
2855
2856 let bob = named_ctx("bob");
2858 let foreign = dispatcher.dispatch(&bob, &request).await;
2859 let err = foreign.error.expect("foreign principal must be denied");
2860 assert_eq!(err.code, codes::SERVER_ERROR);
2861 assert_ne!(
2862 err.code,
2863 codes::UNAUTHENTICATED,
2864 "deny must not surface -32001 (would leak existence info)",
2865 );
2866 assert!(
2867 err.message.contains("task not found"),
2868 "deny-as-not-found expected, got: {}",
2869 err.message,
2870 );
2871 assert!(foreign.result.is_none(), "must not leak the task body");
2872 }
2873
2874 #[tokio::test]
2875 async fn cancel_task_foreign_principal_denied_before_mutation() {
2876 let dispatcher = tenant_bound_dispatcher();
2877 let alice = named_ctx("alice");
2878 let task_id = create_task(&dispatcher, &alice).await;
2879 let _ownership = claim_for(&dispatcher, &task_id, "alice").await;
2880
2881 let bob = named_ctx("bob");
2883 let cancel = dispatcher
2884 .dispatch(&bob, &by_id_request("CancelTask", &task_id))
2885 .await;
2886 let err = cancel.error.expect("foreign cancel must be denied");
2887 assert_eq!(err.code, codes::SERVER_ERROR);
2888 assert!(err.message.contains("task not found"));
2889
2890 let owner_view = dispatcher
2893 .dispatch(&alice, &by_id_request("GetTask", &task_id))
2894 .await;
2895 let status = owner_view
2896 .result
2897 .expect("owner result")
2898 .get("status")
2899 .and_then(|v| v.as_str())
2900 .map(str::to_string);
2901 assert_ne!(
2902 status.as_deref(),
2903 Some("canceled"),
2904 "foreign cancel must not mutate the task",
2905 );
2906 }
2907
2908 struct UnavailableKv;
2911
2912 #[async_trait::async_trait]
2913 impl klieo_core::KvStore for UnavailableKv {
2914 async fn get(
2915 &self,
2916 _: &str,
2917 _: &str,
2918 ) -> Result<Option<klieo_core::KvEntry>, klieo_core::BusError> {
2919 Err(klieo_core::BusError::Connection("kv down".into()))
2920 }
2921 async fn put(
2922 &self,
2923 _: &str,
2924 _: &str,
2925 _: Bytes,
2926 ) -> Result<klieo_core::Revision, klieo_core::BusError> {
2927 Err(klieo_core::BusError::Connection("kv down".into()))
2928 }
2929 async fn cas(
2930 &self,
2931 _: &str,
2932 _: &str,
2933 _: Bytes,
2934 _: Option<klieo_core::Revision>,
2935 ) -> Result<klieo_core::Revision, klieo_core::BusError> {
2936 Err(klieo_core::BusError::Connection("kv down".into()))
2937 }
2938 async fn delete(&self, _: &str, _: &str) -> Result<(), klieo_core::BusError> {
2939 Err(klieo_core::BusError::Connection("kv down".into()))
2940 }
2941 async fn lease(
2942 &self,
2943 _: &str,
2944 _: &str,
2945 _: std::time::Duration,
2946 ) -> Result<klieo_core::Lease, klieo_core::BusError> {
2947 Err(klieo_core::BusError::Connection("kv down".into()))
2948 }
2949 async fn keys(&self, _: &str) -> Result<Vec<String>, klieo_core::BusError> {
2950 Err(klieo_core::BusError::Connection("kv down".into()))
2951 }
2952 }
2953
2954 #[tokio::test]
2955 async fn send_message_fails_closed_when_strict_ownership_store_unavailable() {
2956 let bus = klieo_bus_memory::MemoryBus::new();
2960 let dispatcher = A2aDispatcher::builder()
2961 .handler(Arc::new(EchoHandler::default()))
2962 .authenticator(Arc::new(AllowAnonymous))
2963 .pubsub(bus.pubsub.clone())
2964 .with_tenant_binding_strict(Arc::new(UnavailableKv))
2965 .build()
2966 .expect("strict tenant-bound dispatcher builds");
2967
2968 let resp = dispatcher
2969 .dispatch(&named_ctx("alice"), SEND_MESSAGE_PAYLOAD)
2970 .await;
2971 let err = resp
2972 .error
2973 .expect("strict store-down must fail the create closed");
2974 assert_eq!(err.code, codes::SERVER_ERROR);
2975 assert!(resp.result.is_none(), "denied create must carry no task");
2976 }
2977
2978 #[tokio::test]
2979 async fn list_tasks_fails_closed_when_ownership_store_unavailable() {
2980 let handler = Arc::new(EchoHandler::default());
2984 handler
2985 .send_message(&named_ctx("alice"), hi_params())
2986 .await
2987 .expect("seed task on handler");
2988 let bus = klieo_bus_memory::MemoryBus::new();
2989 let dispatcher = A2aDispatcher::builder()
2990 .handler(handler)
2991 .authenticator(Arc::new(AllowAnonymous))
2992 .pubsub(bus.pubsub.clone())
2993 .with_tenant_binding_strict(Arc::new(UnavailableKv))
2994 .build()
2995 .expect("strict tenant-bound dispatcher builds");
2996
2997 let alice = named_ctx("alice");
2998 let list_request = serde_json::to_vec(&serde_json::json!({
2999 "jsonrpc": "2.0", "id": 4, "method": "ListTasks", "params": {},
3000 }))
3001 .expect("encode ListTasks");
3002 let resp = dispatcher.dispatch(&alice, &list_request).await;
3003 let err = resp
3004 .error
3005 .expect("a strict store-down must fail the list closed");
3006 assert_eq!(err.code, codes::SERVER_ERROR);
3007 assert!(
3008 err.message.contains("unavailable") || err.message.contains("list denied"),
3009 "fail-closed message expected, got: {}",
3010 err.message,
3011 );
3012 assert!(resp.result.is_none(), "must not return a partial list");
3013 }
3014
3015 #[tokio::test]
3016 async fn push_notification_config_arms_deny_foreign_principal() {
3017 let dispatcher = tenant_bound_dispatcher();
3018 let alice = named_ctx("alice");
3019 let task_id = create_task(&dispatcher, &alice).await;
3020 let _ownership = claim_for(&dispatcher, &task_id, "alice").await;
3021 let bob = named_ctx("bob");
3022
3023 let requests = [
3028 serde_json::json!({"jsonrpc":"2.0","id":5,"method":"CreateTaskPushNotificationConfig","params":{"taskId":task_id,"url":"https://example.test/hook"}}),
3029 serde_json::json!({"jsonrpc":"2.0","id":6,"method":"GetTaskPushNotificationConfig","params":{"taskId":task_id,"id":"c1"}}),
3030 serde_json::json!({"jsonrpc":"2.0","id":7,"method":"ListTaskPushNotificationConfigs","params":{"taskId":task_id}}),
3031 serde_json::json!({"jsonrpc":"2.0","id":8,"method":"DeleteTaskPushNotificationConfig","params":{"taskId":task_id,"id":"c1"}}),
3032 ];
3033
3034 for request in requests {
3035 let method = request["method"].as_str().expect("method").to_string();
3036 let body = serde_json::to_vec(&request).expect("encode push-config request");
3037
3038 let foreign = dispatcher.dispatch(&bob, &body).await;
3039 let foreign_err = foreign
3040 .error
3041 .unwrap_or_else(|| panic!("{method}: foreign principal must be denied"));
3042 assert_eq!(
3043 foreign_err.code,
3044 codes::SERVER_ERROR,
3045 "{method}: foreign principal must be denied-as-not-found",
3046 );
3047 assert!(
3048 foreign_err.message.contains("task not found"),
3049 "{method}: expected deny-as-not-found, got {}",
3050 foreign_err.message,
3051 );
3052 assert!(foreign.result.is_none(), "{method}: must not leak a body");
3053
3054 let owner = dispatcher.dispatch(&alice, &body).await;
3055 let owner_err = owner
3056 .error
3057 .unwrap_or_else(|| panic!("{method}: EchoHandler declines push-config"));
3058 assert_eq!(
3059 owner_err.code,
3060 codes::METHOD_NOT_FOUND,
3061 "{method}: owner must pass the gate and reach the handler default",
3062 );
3063 }
3064 }
3065
3066 #[tokio::test]
3067 async fn list_tasks_drops_tasks_the_caller_does_not_own() {
3068 let dispatcher = tenant_bound_dispatcher();
3069 let alice = named_ctx("alice");
3070 let bob = named_ctx("bob");
3071
3072 let alice_task = create_task(&dispatcher, &alice).await;
3074 let bob_task = create_task(&dispatcher, &bob).await;
3075 let _alice_owns = claim_for(&dispatcher, &alice_task, "alice").await;
3076 let _bob_owns = claim_for(&dispatcher, &bob_task, "bob").await;
3077
3078 let list_request = serde_json::to_vec(&serde_json::json!({
3079 "jsonrpc": "2.0", "id": 3, "method": "ListTasks", "params": {},
3080 }))
3081 .expect("encode ListTasks");
3082
3083 let resp = dispatcher.dispatch(&bob, &list_request).await;
3084 let ids: Vec<String> = resp
3085 .result
3086 .expect("list result")
3087 .get("tasks")
3088 .and_then(|v| v.as_array())
3089 .expect("tasks array")
3090 .iter()
3091 .filter_map(|t| t.get("id").and_then(|v| v.as_str()).map(str::to_string))
3092 .collect();
3093
3094 assert!(ids.contains(&bob_task), "owner must see own task: {ids:?}");
3095 assert!(
3096 !ids.contains(&alice_task),
3097 "list must not leak a foreign-owned task: {ids:?}",
3098 );
3099 }
3100}