Skip to main content

klieo_a2a/
server.rs

1//! `A2aServer` — listens on a NATS-style request/reply subject and
2//! dispatches incoming JSON-RPC payloads to an [`A2aHandler`].
3//!
4//! # Transport contract
5//!
6//! - **Subject:** `<app_prefix>.a2a.<agent_id>.rpc`. Subscribe via
7//!   [`klieo_core::Pubsub::subscribe`] with a fixed durable name.
8//! - **Reply-to:** the inbound message MUST carry a `"Reply-To"` header
9//!   naming the subject the client is listening on. The server publishes
10//!   the [`JsonRpcResponse`] there.
11//! - **Headers:** `Authorization`, `Content-Type`, `A2A-Version`,
12//!   `A2A-Extensions` decoded via [`crate::envelope::A2aHeaders`].
13//!
14//! See module docs in `lib.rs` for why the server takes
15//! `Arc<dyn Pubsub>` instead of `Arc<dyn RequestReply>`.
16
17use 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
46/// Default per-dispatcher cap on concurrent in-flight
47/// [`TaskEventSink::send`] publishes. Bounds the runtime work the
48/// best-effort fanout can pile up when a slow bus backend (e.g.
49/// stalled NATS) lets publishes accumulate. Configurable via
50/// [`A2aDispatcher::with_publish_concurrency`].
51pub(crate) const DEFAULT_PUBLISH_PERMITS: usize = 64;
52
53/// Leader-claim TTL for the `klieo-leaders` KV bucket used by
54/// [`A2aDispatcher::dispatch_streaming`] (claim on invoke) and
55/// `dispatch_subscribe_to_task` (orphan check on resume).
56///
57/// Operators MUST configure the JetStream KV bucket with `max_age`
58/// equal to this value so a dead replica's leader entry evicts
59/// automatically; the registry's heartbeat runs every `TTL / 2`
60/// (`leader::LeaderRegistry::claim`). See ADR-020.
61pub const LEADER_TTL: std::time::Duration = std::time::Duration::from_secs(5);
62
63/// Leader-key prefix for the A2A transport in the shared
64/// `klieo-leaders` bucket. Mirrors `mcp.<token>` on the MCP side.
65const A2A_LEADER_KEY_PREFIX: &str = "a2a.";
66
67/// Ownership-key prefix for the A2A transport in the shared
68/// `klieo-tenants` bucket. Same `a2a.{task_id}` shape as the
69/// leader key so operators can correlate the two registries.
70/// ADR-022.
71const A2A_OWNERSHIP_KEY_PREFIX: &str = "a2a.";
72
73fn ownership_key(task_id: &str) -> String {
74    format!("{A2A_OWNERSHIP_KEY_PREFIX}{task_id}")
75}
76
77/// Pinned, boxed stream of [`TaskEvent`]s. Returned by
78/// [`A2aDispatcher::dispatch_streaming`] and consumed by the HTTP
79/// transport's SSE response builder.
80pub type TaskEventStream = Pin<Box<dyn futures::Stream<Item = TaskEvent> + Send>>;
81
82/// Lifecycle event emitted by [`crate::task_store::A2aTaskStore`] on each
83/// mutating write. Serialised as the SSE `data:` payload for
84/// `SubscribeToTask` / `SendStreamingMessage` responses in the HTTP
85/// transport (the `http` cargo feature, not yet wired).
86#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
87#[non_exhaustive]
88pub struct TaskEvent {
89    /// The task whose state transitioned.
90    pub task_id: String,
91    /// Current status post-transition.
92    pub status: TaskStatus,
93    /// Last message in the task history, if any.
94    pub message: Option<Message>,
95    /// `true` if `status` is terminal (Completed / Failed / Canceled /
96    /// Rejected); stream consumers close after receiving it.
97    pub final_event: bool,
98    /// Monotonic per-task event id used by SSE `Last-Event-ID`
99    /// resumption. Set by the HTTP transport when the event is yielded;
100    /// defaults to 0 for events constructed outside the transport
101    /// (e.g. synthetic initial replays from `task_store`).
102    #[serde(default)]
103    pub event_id: u64,
104}
105
106impl TaskEvent {
107    /// Construct a `TaskEvent`. The only public constructor — required because
108    /// `#[non_exhaustive]` prevents struct-literal construction outside this
109    /// crate. `event_id` defaults to `0`; the HTTP transport overwrites it
110    /// when the event is yielded into an SSE stream.
111    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    /// Override the monotonic event id assigned by the store or transport.
127    ///
128    /// Used by `A2aTaskStore::put` to stamp broadcast events, and by
129    /// `dispatch_send_streaming` / `subscribe_snapshot_then_tail` to stamp
130    /// synthetic initial events before they enter the SSE stream.
131    #[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    /// Synthetic event emitted when a buffered event payload cannot
138    /// be decoded back into a `TaskEvent`. Carries `event_id = 0` and
139    /// only the task id; status is `Failed` and `final_event = true`
140    /// so consumers don't hang.
141    // Wired by the SSE resume branch (T8+); unused until then.
142    #[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
154/// Stream wrapper that owns a [`klieo_core::LeaderHandle`] and an
155/// optional [`klieo_core::OwnershipHandle`] for the lifetime of an SSE
156/// response body. Polling delegates to `inner`; `Drop` releases the
157/// leader claim (heartbeat abort + best-effort KV delete inside
158/// `LeaderHandle::drop`) and the ownership entry (best-effort
159/// `kv.delete` spawned inside `OwnershipHandle::drop`) when the body
160/// drops.
161///
162/// Both handle fields are `Option` so the same wrapper works when
163/// leader election or tenant binding are disabled (`None` → behaves
164/// as a transparent pass-through for that channel).
165struct LeaderHoldStream<S> {
166    inner: S,
167    // Held for Drop side effect — never inspected.
168    _leader: Option<klieo_core::LeaderHandle>,
169    // Held for Drop side effect — never inspected. ADR-022.
170    _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
183/// Outcome of a leader-alive probe at `SubscribeToTask` entry.
184enum LeaderProbe {
185    /// No registry wired — single-replica deployment, orphan detection skipped.
186    NoRegistry,
187    /// Probe returned `Ok(true)` OR the KV errored (fail-open per ADR-020).
188    Alive,
189    /// Probe returned `Ok(false)` — the leader's claim has lapsed or never existed.
190    Dead,
191}
192
193/// Publish wrapper for cross-replica task event fanout. Replaces the
194/// previous `tokio::sync::broadcast::Sender<TaskEvent>` returned by
195/// [`A2aDispatcher::event_sink`].
196///
197/// The sink shares an [`Arc<Semaphore>`] with its parent
198/// [`A2aDispatcher`] so [`Self::send`] can bound the number of
199/// concurrent in-flight publishes. See [`Self::send`] for the
200/// saturation-drop semantics.
201#[derive(Clone)]
202pub struct TaskEventSink {
203    pubsub: Arc<dyn klieo_core::Pubsub>,
204    permits: Arc<Semaphore>,
205}
206
207impl TaskEventSink {
208    /// Wrap an `Arc<dyn Pubsub>` for publishing per-task events with a
209    /// fresh, single-replica default semaphore
210    /// (`DEFAULT_PUBLISH_PERMITS` permits). Multi-replica deployments
211    /// or any wiring that wants to share the dispatcher's bound should
212    /// go through [`A2aDispatcher::event_sink`] instead, which threads
213    /// the dispatcher's semaphore through.
214    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    /// Wrap an `Arc<dyn Pubsub>` with an explicit publish-concurrency
219    /// semaphore. Used by [`A2aDispatcher::event_sink`] to share the
220    /// dispatcher's bound across every sink it hands out. The
221    /// semaphore's `available_permits()` caps the number of concurrent
222    /// publishes [`Self::send`] will execute; excess sends drop with
223    /// `Ok(())` and a warn log.
224    pub fn with_permits(pubsub: Arc<dyn klieo_core::Pubsub>, permits: Arc<Semaphore>) -> Self {
225        Self { pubsub, permits }
226    }
227
228    /// Publish `event` on the per-task bus subject
229    /// `klieo.a2a.task.{event.task_id}`.
230    ///
231    /// `task_id` is validated against
232    /// [`klieo_core::validate_subject_token`] before subject
233    /// construction; metacharacters (`.`, `*`, `>`, whitespace,
234    /// non-ASCII) yield `A2aError::Bus(BusError::Invalid(_))`,
235    /// preventing a caller-controlled task id from collapsing or
236    /// wildcarding the subject namespace (CWE-74).
237    ///
238    /// # Backpressure
239    ///
240    /// Acquires a permit from the shared semaphore via
241    /// [`Semaphore::try_acquire_owned`] BEFORE awaiting the publish.
242    /// When the semaphore is saturated the event is dropped, a `warn`
243    /// is logged at target `a2a.fanout` with
244    /// `available_permits = 0`, and the method returns `Ok(())` — task
245    /// event fanout is best-effort (matches the cluster-0.18 contract
246    /// in [`crate::task_store::A2aTaskStore`] which already tolerates
247    /// publish failures), and surfacing the saturation as an error
248    /// would propagate into store writes that succeeded.
249    ///
250    /// Publish failures surface as `A2aError::Bus(_)`; server-side
251    /// encode failures (effectively impossible for a server-built
252    /// `TaskEvent`) surface as `A2aError::Internal { source }` so
253    /// the wire envelope is `SERVER_ERROR` (`-32000`) rather than
254    /// `PARSE_ERROR` (`-32700`). Both preserve `e.source()` for
255    /// downstream tracing.
256    #[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        // Inject W3C tracecontext into bus headers so subscribers on
277        // other replicas can stitch their decode span under the
278        // publisher's span (cluster 0.23, ADR-023).
279        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(()); // best-effort drop
293            }
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/// Builder for [`A2aDispatcher`] mirroring the shape of
306/// `klieo_mcp_server::McpServerBuilder`:
307/// accumulate `handler` / `authenticator` / `pubsub`, optionally
308/// flag [`Self::with_cancel_subscription`], then finalise with
309/// [`Self::build`] (unwrapped) or [`Self::build_arc`] (Arc, spawns
310/// the wildcard cancel subscriber when the flag is set).
311///
312/// Symmetry with `McpServerBuilder` was carried as an architecture
313/// gate medium for several rounds in cluster 0.18 — A2A previously
314/// required a post-construction
315/// [`A2aDispatcher::with_cancel_subscription`] call against an
316/// already-wrapped [`Arc`], while MCP folded the same opt-in into
317/// the builder. T5 closes the asymmetry; the pre-existing
318/// [`A2aDispatcher::new`] shortcut and the post-construction method
319/// remain for single-replica callers.
320#[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    /// Set the [`A2aHandler`] that dispatched requests delegate to.
339    pub fn handler(mut self, handler: Arc<dyn A2aHandler>) -> Self {
340        self.handler = Some(handler);
341        self
342    }
343
344    /// Set the [`Authenticator`] enforced at every request boundary.
345    /// `handle_request` / `handle_streaming` authenticate before
346    /// touching the handler (CWE-306).
347    pub fn authenticator(mut self, authenticator: Arc<dyn Authenticator>) -> Self {
348        self.authenticator = Some(authenticator);
349        self
350    }
351
352    /// Apply a [`DeploymentProfile`](klieo_core::DeploymentProfile). Regulated
353    /// profiles force strict tenant binding + require a non-anonymous
354    /// authenticator; `build()` fails closed if a prerequisite is missing.
355    pub fn profile(mut self, profile: klieo_core::DeploymentProfile) -> Self {
356        self.profile = profile;
357        self
358    }
359
360    /// Wire an external [`klieo_core::Pubsub`] for cross-replica
361    /// fanout (task events + cancel signals). Multi-replica
362    /// deployments pass a shared NATS-backed pubsub.
363    pub fn pubsub(mut self, pubsub: Arc<dyn klieo_core::Pubsub>) -> Self {
364        self.pubsub = Some(pubsub);
365        self
366    }
367
368    /// Convenience: wire a fresh in-process
369    /// [`klieo_bus_memory::MemoryBus`] pubsub. Single-replica
370    /// deployments only — multi-replica wiring MUST go through
371    /// [`Self::pubsub`].
372    #[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    /// Override the per-dispatcher publish-concurrency cap (default
379    /// `DEFAULT_PUBLISH_PERMITS`). Shared with every
380    /// [`TaskEventSink`] handed out by [`A2aDispatcher::event_sink`]
381    /// and with the drop-time cross-replica cancel publish.
382    #[must_use]
383    pub fn with_publish_concurrency(mut self, permits: usize) -> Self {
384        self.publish_concurrency = Some(permits);
385        self
386    }
387
388    /// Spawn the wildcard `klieo.a2a.cancel.>` background subscriber
389    /// on [`Self::build_arc`]. Required for multi-replica deployments.
390    /// The spawned task holds an [`Arc`] clone of the dispatcher, so
391    /// this flag is only honoured by [`Self::build_arc`];
392    /// [`Self::build`] panics if the flag is set.
393    #[must_use]
394    pub fn with_cancel_subscription(mut self) -> Self {
395        self.subscribe_cancels = true;
396        self
397    }
398
399    /// Opt in to leader election for multi-replica orphan
400    /// detection. On invoke start `dispatch_streaming` claims
401    /// leadership in the `klieo-leaders` KV bucket; on
402    /// `SubscribeToTask` the dispatcher checks the leader is
403    /// alive and writes a terminal "leader died" SSE frame to
404    /// the resume buffer if not.
405    ///
406    /// Operators MUST configure the `klieo-leaders` JetStream KV
407    /// bucket with `max_age = 5s` (matches the cluster's fixed
408    /// TTL). See ADR-020.
409    #[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    /// Opt in to tenant binding. On invoke start the dispatcher
416    /// claims ownership in the `klieo-tenants` KV bucket keyed
417    /// by the authenticated `Identity.principal`; on
418    /// `SubscribeToTask` the dispatcher rejects mismatched
419    /// principals as `TaskNotFound` (deny-as-NotFound per
420    /// OWASP IDOR best practice).
421    ///
422    /// Operators MUST also wire an `Authenticator` that
423    /// produces non-anonymous identities (typically OAuth via
424    /// cluster 0.21); without it no entries are written and
425    /// no checks run. See ADR-022.
426    #[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    /// Like [`with_tenant_binding`](Self::with_tenant_binding) but **fail-closed**
434    /// on store error: when the `klieo-tenants` KV is unreachable, an invoke
435    /// claim and a `SubscribeToTask` ownership check both DENY rather than
436    /// proceed, closing the transient-KV-blip cross-tenant-resume window for
437    /// regulated multi-tenant deployments (at the cost of availability during a
438    /// KV outage). An unclaimed key still proceeds — partial-deployment posture
439    /// is unchanged. See ADR-022.
440    #[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    /// Override the cluster-0.20 leader TTL (default [`LEADER_TTL`],
448    /// 5s). Wider TTL tolerates network blips at the cost of slower
449    /// dead-leader detection; tighter TTL detects deaths faster but
450    /// risks spurious orphan probes under load. The heartbeat
451    /// interval defaults to `ttl / 2` — override independently via
452    /// [`Self::with_leader_heartbeat_interval`].
453    #[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    /// Override the leader heartbeat interval. Default is half the
460    /// configured TTL (or [`LEADER_TTL`] / 2 when the TTL itself is
461    /// at its default). Tighten when KV write latency is high and
462    /// you want extra headroom before TTL expires.
463    #[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    /// Override the cluster-0.24 failover-attempt cap (default
470    /// [`klieo_core::FAILOVER_ATTEMPT_CAP`], 3). Higher cap
471    /// accommodates flaky downstream where bad-state tools are
472    /// rare; lower cap exits failover loops faster.
473    #[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    /// Opt in to the cluster-0.25 KV reaper. Spawns a background
480    /// task scanning `klieo-leaders` (+ `klieo-tenants` when tenant
481    /// binding is wired) every `interval`, evicting entries whose
482    /// resume buffer is terminal. The scan task is spawned inside
483    /// [`crate::http::A2aHttpServer::with_resume_buffer`] (which is
484    /// where the resume buffer the reaper needs becomes available);
485    /// calling this method on a dispatcher whose HTTP server never
486    /// receives a non-noop resume buffer is a no-op.
487    ///
488    /// Recommended: 60s for production NATS deployments running
489    /// `klieo-leaders` / `klieo-tenants` without bucket TTLs. Bucket
490    /// TTLs are still the primary eviction mechanism — the reaper
491    /// is a backstop for deployments where TTLs are not configured.
492    #[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    /// Finalise into a raw [`A2aDispatcher`].
499    ///
500    /// Returns [`A2aBuilderError::CancelRequiresArc`] when
501    /// [`Self::with_cancel_subscription`] was set — the background
502    /// subscriber requires an [`Arc`] clone; call [`Self::build_arc`] instead.
503    /// Returns [`A2aBuilderError::MissingHandler`],
504    /// [`A2aBuilderError::MissingAuthenticator`], or
505    /// [`A2aBuilderError::MissingPubsub`] when the corresponding required
506    /// field was not set on the builder.
507    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    /// Finalise into `Arc<A2aDispatcher>`. When
573    /// [`Self::with_cancel_subscription`] was set, also spawns the
574    /// wildcard cancel subscriber against the returned [`Arc`].
575    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
585/// Transport-agnostic dispatcher. Holds the handler and
586/// authenticator. Both [`A2aServer`] (NATS) and the future
587/// `A2aHttpServer` (HTTP, feature-gated) delegate to a shared
588/// instance.
589pub 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    /// Open an [`A2aDispatcherBuilder`] for accumulating handler /
605    /// authenticator / pubsub and opting into cross-replica cancel
606    /// subscription before finalising via
607    /// [`A2aDispatcherBuilder::build`] or
608    /// [`A2aDispatcherBuilder::build_arc`].
609    ///
610    /// Mirrors `klieo_mcp_server::McpServerBuilder`
611    /// — see [`A2aDispatcherBuilder`] for the closed asymmetry.
612    /// [`Self::new`] remains as a single-replica shortcut.
613    pub fn builder() -> A2aDispatcherBuilder {
614        A2aDispatcherBuilder::default()
615    }
616
617    /// Build a dispatcher with the given pubsub for cross-replica fanout.
618    ///
619    /// The dispatcher constructs an [`Arc<Semaphore>`] with
620    /// `DEFAULT_PUBLISH_PERMITS` (64) permits to bound concurrent
621    /// in-flight [`TaskEventSink::send`] publishes. Tune via
622    /// [`Self::with_publish_concurrency`].
623    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    /// Configured leader TTL (cluster-0.20 + cluster-0.25 tunable).
644    /// Default [`LEADER_TTL`] when the builder method
645    /// [`A2aDispatcherBuilder::with_leader_ttl`] was not called.
646    pub fn leader_ttl(&self) -> std::time::Duration {
647        self.leader_ttl
648    }
649
650    /// Configured leader heartbeat interval. Default is half the
651    /// configured TTL when [`A2aDispatcherBuilder::with_leader_heartbeat_interval`]
652    /// was not called.
653    pub fn leader_heartbeat_interval(&self) -> std::time::Duration {
654        self.leader_heartbeat_interval
655    }
656
657    /// Configured failover-attempt cap (cluster-0.24 + cluster-0.25
658    /// tunable). Default [`klieo_core::FAILOVER_ATTEMPT_CAP`] when the
659    /// builder method [`A2aDispatcherBuilder::with_max_failover_attempts`]
660    /// was not called.
661    pub fn max_failover_attempts(&self) -> u32 {
662        self.max_failover_attempts
663    }
664
665    /// Configured KV-reaper interval, if any. `None` when
666    /// [`A2aDispatcherBuilder::with_kv_reaper`] was not called.
667    /// [`crate::http::A2aHttpServer::with_resume_buffer`] spawns
668    /// the reaper task when this is `Some`.
669    pub fn kv_reaper_interval(&self) -> Option<std::time::Duration> {
670        self.kv_reaper_interval
671    }
672
673    /// Borrow the leader registry if configured via the builder's
674    /// `with_leader_election`. None when running single-replica
675    /// without orphan detection.
676    pub fn leader_registry(&self) -> Option<&klieo_core::LeaderRegistry> {
677        self.leader_registry.as_ref()
678    }
679
680    /// Borrow the ownership registry if configured via the
681    /// builder's `with_tenant_binding`. None when running
682    /// without tenant binding (pre-0.22 deployments).
683    pub fn ownership_registry(&self) -> Option<&klieo_core::OwnershipRegistry> {
684        self.ownership_registry.as_ref()
685    }
686
687    /// Replace the dispatcher's publish-concurrency cap. Every
688    /// [`TaskEventSink`] returned by [`Self::event_sink`] after this
689    /// call shares the new semaphore; previously-issued sinks keep
690    /// their old reference. Pass `0` to drop all fanout publishes
691    /// (useful in tests that want to assert the saturation branch).
692    /// Pass a larger value when the bus backend has demonstrated
693    /// headroom and 64 in-flight publishes are a bottleneck.
694    #[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    /// Convenience: builds with a fresh in-process `MemoryBus` pubsub.
701    /// Single-replica deployments only. Multi-replica deployments wire a
702    /// shared `Arc<dyn Pubsub>` via [`A2aDispatcher::new`].
703    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    /// Capability-shaped default for laptop-dev — wires
712    /// [`klieo_auth_common::AllowAnonymous`] and a fresh in-process
713    /// [`klieo_bus_memory::MemoryBus`] pubsub around the supplied
714    /// handler.
715    ///
716    /// **TEST FIXTURE / DEMO ONLY.** Gated behind the `test-fixtures`
717    /// feature (CWE-1188) so production builds cannot reach the
718    /// permissive authenticator. Use [`Self::new`] or [`Self::builder`]
719    /// for production wiring with a real
720    /// [`klieo_auth_common::Authenticator`].
721    #[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    /// Borrow the dispatcher's authenticator. Used by
732    /// [`crate::http::A2aHttpServer`] to inspect
733    /// `Authenticator::allows_anonymous` before binding.
734    pub fn authenticator(&self) -> &Arc<dyn Authenticator> {
735        &self.authenticator
736    }
737
738    /// Borrow the dispatcher's handler. Used by
739    /// [`crate::http::A2aHttpServer`] to serve
740    /// `GET /.well-known/agent-card.json` directly from
741    /// [`A2aHandler::get_agent_card`] without round-tripping through the
742    /// JSON-RPC envelope — that endpoint is a plain REST resource, not
743    /// an RPC call.
744    pub fn handler(&self) -> &Arc<dyn A2aHandler> {
745        &self.handler
746    }
747
748    /// Get a publish wrapper for hand-emitting TaskEvents (e.g. from
749    /// `A2aTaskStore::with_event_sink`). The returned sink shares the
750    /// dispatcher's publish-concurrency semaphore, so every sink
751    /// handed out by one dispatcher is bounded by the same cap (see
752    /// [`Self::with_publish_concurrency`]).
753    pub fn event_sink(&self) -> TaskEventSink {
754        TaskEventSink::with_permits(self.pubsub.clone(), self.publish_permits.clone())
755    }
756
757    /// Borrow the configured pubsub.
758    pub fn pubsub(&self) -> &Arc<dyn klieo_core::Pubsub> {
759        &self.pubsub
760    }
761
762    /// Borrow the dispatcher's publish-concurrency semaphore. Shared
763    /// with every [`TaskEventSink`] this dispatcher hands out and
764    /// threaded into `CancelOnDrop` (private wrapper in `crate::http`) so SSE-stream
765    /// drop-time cross-replica cancel publishes bound under the same
766    /// cap as task-event fanout. See [`Self::with_publish_concurrency`]
767    /// for the dial.
768    pub fn publish_permits(&self) -> &Arc<Semaphore> {
769        &self.publish_permits
770    }
771
772    /// Borrow the per-dispatcher [`klieo_core::CancelRegistry`] keyed by
773    /// task id. The cancel-subject subscription task and the invoke
774    /// dispatch path share a single registry instance: invoke registers
775    /// a [`tokio_util::sync::CancellationToken`] under the task id at
776    /// invocation start, the subscription task fires it on inbound
777    /// `klieo.a2a.cancel.{task_id}` messages, and invoke deregisters in
778    /// a finally-style cleanup once the invocation terminates.
779    pub fn cancel_registry(&self) -> &klieo_core::CancelRegistry<String> {
780        &self.cancel_registry
781    }
782
783    /// Publish a cross-replica cancel signal for `task_id` on
784    /// `klieo.a2a.cancel.{task_id}`. Thin wrapper around
785    /// [`klieo_core::cancel::publish_cancel_signal`] — see that
786    /// helper for the full subject-validation + publish contract.
787    /// `BusError` surfaces as `A2aError::Bus(_)` via the
788    /// `#[from]` impl.
789    ///
790    /// # Security
791    /// `task_id` is validated against
792    /// [`klieo_core::validate_subject_token`] before subject
793    /// construction; metacharacters (`.`, `*`, `>`, whitespace,
794    /// non-ASCII) yield `A2aError::Bus(BusError::Invalid(_))`,
795    /// preventing a caller-controlled task id from collapsing or
796    /// wildcarding the subject namespace (CWE-74). Same guard
797    /// shape as [`TaskEventSink::send`] on the per-task event
798    /// subject.
799    ///
800    /// Cancel signals share the progressToken-as-credential threat
801    /// model documented for resume in ADR-018 / ADR-019: knowledge
802    /// of the task id grants the ability to cancel the invocation.
803    /// Operators MUST mint unguessable task ids (UUID v4 or
804    /// stronger) and gate per-tenant authorisation BEFORE the
805    /// request reaches this dispatcher; without that, cross-tenant
806    /// cancel becomes possible (CWE-639 IDOR).
807    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    /// Spawn the wildcard cancel-subject background subscriber.
814    ///
815    /// Thin wrapper around
816    /// [`klieo_core::cancel::spawn_wildcard_cancel_subscriber`] —
817    /// see that helper for the loop body, ack policy, and
818    /// startup-failure semantics. Required for multi-replica
819    /// deployments; without it, only same-replica drop-cancel
820    /// works.
821    ///
822    /// Idempotent at the call-site level: calling twice spawns
823    /// two subscribers (not recommended; both will ack
824    /// independently and the bus will balance delivery).
825    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    /// Dispatch one non-streaming JSON-RPC request.
836    ///
837    /// This is a flat dispatch table mapping each [`A2aMethod`] variant to
838    /// its handler. Length exceeds the 40-line guideline by design —
839    /// splitting the match into helper functions fragments the routing logic
840    /// and makes it harder to see all method mappings at a glance.
841    #[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            // Normalise to the canonical wire name before handing it to
859            // the authenticator: a scope-based `authorize_method` keyed
860            // on canonical method names (e.g. denying "CancelTask")
861            // must see the SAME name whether the caller used the
862            // current CamelCase form or a legacy slash-form alias —
863            // otherwise the alias silently bypasses the deny rule.
864            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                    // A non-streaming SendMessage creates a task that outlives the
892                    // request and is later reached via GetTask/CancelTask/push-config
893                    // — all of which gate on `enforce_owner`. Without a persistent
894                    // ownership record an unclaimed task is Allowed for every tenant
895                    // (CWE-639 IDOR). Claim ownership before returning, mirroring the
896                    // streaming path's `try_claim_ownership`.
897                    if let SendMessageResult::Task(task) = &result {
898                        self.claim_owner_persistent(ctx, &task.id).await?;
899                    }
900                    Ok(result)
901                })
902                .await
903            }
904            // Streaming methods are only valid via dispatch_streaming;
905            // the non-streaming dispatcher returns MethodNotFound so
906            // NATS callers get a clear -32601 rather than a silent type error.
907            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                    // Gate BEFORE the handler so a foreign cancel never reaches
928                    // the cross-replica cancel publish.
929                    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                    // SSRF guard (CWE-918): `p.url` is fully caller-controlled
938                    // and the server (or a downstream handler override) will
939                    // later POST to it. Reject before the ownership check so
940                    // a malformed URL never depends on task-existence timing.
941                    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    /// Handle one unverified A2A JSON-RPC request: authenticate
996    /// the caller, then dispatch on success.
997    ///
998    /// Returns a ready [`JsonRpcResponse`] in both the success and
999    /// authentication-failure cases so callers never need to branch.
1000    ///
1001    /// # Security
1002    /// Authenticates before deserialising or dispatching
1003    /// (CWE-306). On auth failure the payload never reaches the
1004    /// handler; the caller receives a JSON-RPC `-32001`
1005    /// envelope.
1006    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    /// Streaming-method analog of [`Self::handle_request`].
1032    /// Authenticates first (CWE-306), constructs a [`RequestContext`],
1033    /// then delegates to [`Self::dispatch_streaming`]. Used by the HTTP
1034    /// transport for `SendStreamingMessage` and `SubscribeToTask`.
1035    ///
1036    /// # Security
1037    /// Authenticates before deserialising or dispatching (CWE-306). On
1038    /// auth failure returns [`A2aError::Unauthorized`]; the payload never
1039    /// reaches the handler.
1040    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    /// Dispatch a streaming method (`SendStreamingMessage` or
1063    /// `SubscribeToTask`). Returns a [`TaskEventStream`] the HTTP
1064    /// transport serialises as SSE frames.
1065    ///
1066    /// The handler is tried first. On `Ok(stream)` the dispatcher
1067    /// returns it unchanged (handler override). On
1068    /// `Err(MethodNotFound)` the dispatcher synthesises a
1069    /// broadcast-derived stream:
1070    /// - `SendStreamingMessage`: converts params, calls
1071    ///   `send_message`, then tails the broadcast filtered by the
1072    ///   returned task id.
1073    /// - `SubscribeToTask`: reads current state from `task_store`,
1074    ///   emits a synthetic replay event, then tails the broadcast
1075    ///   until the first `final_event = true`.
1076    ///
1077    /// # Security
1078    /// Requires a pre-authenticated [`RequestContext`]. Callers
1079    /// must authenticate before invoking this method (the NATS
1080    /// path's [`A2aDispatcher::handle_request`] does this; an
1081    /// HTTP caller must authenticate at the request boundary).
1082    /// Passing an unauthenticated context violates CWE-306.
1083    #[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            // See the non-streaming `dispatch` for why this normalises
1103            // to `method.wire_name()` rather than the raw request string.
1104            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                // Resumption is subscribe-only in v1 — SendStreamingMessage
1133                // ignores last_event_id and resume_buffer.
1134                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    /// Common body for `dispatch_send_streaming` and the cluster-0.24
1172    /// follower re-invoke path. When `existing_leader` is `Some`, skips
1173    /// the on-invoke leader claim and adopts the caller-provided handle
1174    /// (which already encodes attempt+1 and the cached payload via the
1175    /// follower's CAS claim). When `None`, captures the original params
1176    /// + principal and claims a fresh leader entry.
1177    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        // Capture the original params for cluster-0.24 failover
1194        // re-invoke BEFORE consuming `params` in the synthetic
1195        // fallback below. A serialisation failure here is non-fatal:
1196        // the invoke proceeds with `payload = None` and any follower
1197        // that detects an orphan will terminate (no payload to
1198        // re-invoke from).
1199        let payload_bytes_for_failover = match existing_leader {
1200            Some(_) => None,
1201            None => match serde_json::to_vec(&params) {
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                // Non-task result — no task id to tail; return an
1227                // immediately-closed stream.
1228                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            // Task already in a terminal state — emit the synthetic event
1244            // and close. No pubsub subscription needed, and no risk of
1245            // hanging when the handler (e.g. EchoHandler) doesn't write
1246            // through to a store.
1247            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        // Adopt the follower-supplied handle on cluster-0.24
1255        // re-invoke; otherwise claim fresh leadership for the
1256        // lifetime of the SSE response (multi-replica orphan
1257        // detection, ADR-020). KV-layer failures on a fresh claim
1258        // degrade silently: log warn + proceed without a claim.
1259        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    /// Tenant-ownership claim for a streaming invoke. Returns `Ok(None)` (no
1275    /// claim, proceed) when no registry is wired, or the caller is anonymous
1276    /// (no principal to bind). On KV `put` failure the lenient registry returns
1277    /// `Ok(None)` (fail-open per ADR-022; the later `SubscribeToTask` lookup sees
1278    /// no entry and proceeds unbound), while a **strict** registry returns
1279    /// `Err` so the invoke is denied rather than started unprotected.
1280    ///
1281    /// The returned `OwnershipHandle` MUST be kept alive for the lifetime of the
1282    /// SSE response; the `LeaderHoldStream` wrapper carries it to drop-time so
1283    /// the `kv.delete` fires on stream end.
1284    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            // `OwnershipClaim` is non_exhaustive: an unrecognised future outcome
1307            // is treated as deny — the fail-closed default for a security gate.
1308            _ => Err(A2aError::Server(
1309                "ownership claim inconclusive; invoke denied".into(),
1310            )),
1311        }
1312    }
1313
1314    /// Record persistent tenant ownership for a task created by a non-streaming
1315    /// invoke. The streaming-path analogue [`Self::try_claim_ownership`] holds a
1316    /// drop-guard for the SSE lifetime; here the task outlives the request, so
1317    /// the entry must persist (reclaimed by the tenants-bucket reaper/TTL). No
1318    /// registry / no caller / anonymous caller → `Ok(())` (partial-deployment
1319    /// skip, ADR-022). On a strict-registry store error the invoke is denied so
1320    /// the task is never left readable by every tenant via `enforce_owner`.
1321    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            // `OwnershipRecord` is non_exhaustive: an unrecognised future outcome
1345            // is treated as deny — the fail-closed default for a security gate.
1346            _ => Err(A2aError::Server(
1347                "ownership record inconclusive; invoke denied".into(),
1348            )),
1349        }
1350    }
1351
1352    /// SubscribeToTask tenant-binding gate. Looks up the owner in the
1353    /// `klieo-tenants` bucket and matches against the caller's
1354    /// principal.
1355    ///
1356    /// - Owner match → `Ok(())` (proceed).
1357    /// - Owner mismatch → `Err(A2aError::TaskNotFound)` — deny-as-
1358    ///   NotFound per OWASP IDOR best practice; same wire shape as a
1359    ///   nonexistent task so the response leaks no existence info.
1360    /// - No registry / no caller / anonymous caller → `Ok(())`
1361    ///   (partial-deployment skip, ADR-022).
1362    /// - Missing entry (`Ok(None)`) → `Ok(())` (legacy pre-0.22
1363    ///   stream or no auth wired at invoke).
1364    /// - KV lookup error → `Ok(())` (fail-open; same posture as the
1365    ///   leader is_alive probe in ADR-020).
1366    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                // Mismatch — deny-as-NotFound. No log of the claimed owner;
1381                // just the rejected principal so operators can spot IDOR
1382                // probes without leaking tenancy across the wire.
1383                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    /// Filter a `ListTasks` page down to the tasks the caller owns.
1414    ///
1415    /// Per-task analogue of [`Self::enforce_owner`]; same partial-deployment
1416    /// skip (no registry / no caller / anonymous → no filtering, ADR-022).
1417    /// A `Denied` task is dropped silently so the page cannot become a
1418    /// cross-tenant enumeration oracle (OWASP IDOR); an `Unavailable` verdict
1419    /// fails the whole list closed.
1420    ///
1421    /// [`klieo_core::KvStore`] has no multi-get, so the per-task checks are
1422    /// issued concurrently (one fan-out, ~one round-trip of latency) rather
1423    /// than serially — a page of N tasks must not stall on N sequential
1424    /// round-trips.
1425    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    /// Best-effort leader claim for a streaming invoke. Returns
1474    /// `None` when no registry is wired or when the KV `put` fails
1475    /// (fail-open per ADR-020). The returned `LeaderHandle` MUST be
1476    /// kept alive for the lifetime of the SSE response; the
1477    /// `LeaderHoldStream` wrapper carries it to drop-time.
1478    ///
1479    /// `payload` carries the original invoke params serialised to
1480    /// JSON bytes so a follower that detects an orphan can re-invoke
1481    /// an idempotent handler from the cached payload (cluster 0.24).
1482    /// `principal` records the authenticated subject (cluster 0.21)
1483    /// so the re-invoke runs under the same tenant binding as the
1484    /// original.
1485    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    /// Orphan recovery: on a dead leader probe, write a terminal
1518    /// "leader died" SSE frame at `max_id + 1` to the resume buffer
1519    /// and mark the buffer terminal. Future resume clients replaying
1520    /// past `max_id` see the synthetic frame and close.
1521    ///
1522    /// Returns `Some(())` when the orphan write landed (caller raises
1523    /// [`A2aError::LeaderDied`] to terminate the current request);
1524    /// `None` when:
1525    /// - The buffer has no retained events (leader never claimed +
1526    ///   never wrote → not an orphan, just a non-streaming client).
1527    ///   Caller falls through to the regular snapshot+tail path.
1528    /// - The terminal-frame `record` failed (best-effort: log warn +
1529    ///   fall through to the regular subscribe path so a transient
1530    ///   KV blip does not surface as a hard-fail to the client).
1531    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    /// Cluster-0.24 follower-side response to a dead-leader probe.
1567    /// Returns:
1568    /// - `Ok(Some(stream))` when the follower re-invoked an idempotent
1569    ///   handler from the cached payload — the returned stream owns
1570    ///   the new leader claim via `LeaderHoldStream`.
1571    /// - `Ok(None)` when the follower wrote the terminal "leader died"
1572    ///   frame OR no orphan was detected (no resume-buffer entries) —
1573    ///   caller falls through to the regular snapshot+tail path on
1574    ///   `None`-from-no-orphan and the `LeaderDied` error path is
1575    ///   embedded as an `Err` returned from this method on terminate.
1576    /// - `Err(A2aError::LeaderDied { .. })` when the terminate-only
1577    ///   path fired (cluster-0.20 fallback).
1578    ///
1579    /// Public under `test-fixtures` ONLY so cluster-0.24 integration
1580    /// tests can drive the gate directly. Production callers reach
1581    /// this method via `dispatch_subscribe_to_task` after a
1582    /// `LeaderProbe::Dead` probe; tests bypass the probe because the
1583    /// probe and `lookup_entry_with_revision` both go through the
1584    /// same `kv.get` (an entry either exists or doesn't — there is
1585    /// no production window in which one probe sees None and the
1586    /// other sees Some, so an end-to-end HTTP-driven test that mocks
1587    /// "dead leader" via `kv.delete` collapses the entry visibility
1588    /// for the lookup too).
1589    #[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    /// Test-only public alias for the cluster-0.24 orphan re-invoke
1602    /// gate. See the doc above for why integration tests reach the
1603    /// gate directly instead of driving through `is_alive`.
1604    #[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            // No readable entry (Ok(None) or KV error) — fall back to
1631            // terminate-only so the cluster-0.20 contract still holds.
1632            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    /// Write the cluster-0.24 `failover-reinvoke` marker frame to the
1711    /// resume buffer at `max+1`. Best-effort: a record failure logs a
1712    /// warn + continues so the re-invoke still drives the production
1713    /// stream.
1714    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            // No retained events — re-invoke writes at event_id 1 onwards
1723            // via the regular task_store path; no marker frame needed.
1724            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    /// Build a fresh [`RequestContext`] for the failover re-invoke.
1745    /// The original `ctx.headers` are reused so handlers that read
1746    /// transport metadata see the resume request's framing; the
1747    /// caller identity is replaced with the cached principal so the
1748    /// re-invoke runs under the original tenant binding rather than
1749    /// the resume client's identity (cluster 0.22).
1750    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    /// Terminate-only orphan response (cluster 0.20 path). Writes the
1762    /// "leader died" frame to the resume buffer + raises
1763    /// [`A2aError::LeaderDied`]. Returns `Ok(None)` (caller falls
1764    /// through to the regular subscribe path) when no buffer entries
1765    /// exist — same shape as the inline pre-0.24 logic.
1766    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    /// Best-effort leader-alive probe for a SubscribeToTask entry.
1784    ///
1785    /// Returns `LeaderProbe::NoRegistry` when leader election is not
1786    /// wired; otherwise probes the KV. `Err(_)` from the registry
1787    /// fails open (treat as alive) per the ADR-020 posture: a
1788    /// transient KV blip must NOT trip an orphan write that would
1789    /// terminate a live stream prematurely.
1790    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    /// # Authorization
1811    ///
1812    /// The synthetic fallback path (when the handler returns
1813    /// `MethodNotFound`) reads the task from [`crate::task_store::A2aTaskStore`]
1814    /// keyed by `params.id` without a per-task ACL check. Task IDs are
1815    /// UUID v4 (unguessable) by convention, but adopters that need
1816    /// per-task ownership enforcement should override
1817    /// [`A2aHandler::subscribe_to_task`] and perform the check before
1818    /// returning the stream.
1819    ///
1820    /// When `last_event_id` is set and the `resume_buffer` has retained
1821    /// events for the task, events since that id are replayed first, then
1822    /// the live broadcast tail is appended (with dedup by `event_id` to
1823    /// handle the subscribe-before-replay race).  Falls back to
1824    /// `subscribe_snapshot_then_tail` when the buffer returns `NotFound`
1825    /// or a backend error.  Returns [`A2aError::ResumeBufferExpired`] when
1826    /// the cursor predates the oldest retained event.
1827    #[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        // Tenant binding gate (ADR-022). Runs BEFORE the leader probe
1845        // + task_store lookup so a cross-tenant probe cannot
1846        // distinguish "exists but owned by someone else" from
1847        // "doesn't exist" via timing or downstream errors. On
1848        // mismatch returns TaskNotFound — sanitised on the HTTP wire
1849        // by `dispatch_streaming` into the same `-32000 internal
1850        // server error` envelope as the nonexistent-task path so the
1851        // response leaks no existence info (OWASP IDOR).
1852        self.enforce_owner(ctx, &params.id).await?;
1853        // Orphan detection: leader probe at the SubscribeToTask entry.
1854        // When the leader is dead AND the resume buffer has retained
1855        // events (the leader must have written some before dying), the
1856        // follower either re-invokes from the cached payload (cluster
1857        // 0.24, idempotent handler + attempt cap + payload available)
1858        // OR writes the terminal "leader died" SSE frame and raises
1859        // [`A2aError::LeaderDied`] (cluster 0.20 fallback).
1860        if let LeaderProbe::Dead = self.probe_leader(&params.id).await {
1861            if let Some(stream) = self
1862                .handle_dead_leader_orphan(ctx, &params.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            // Gate the resume branch through the handler's ACL: adopters that
1870            // override A2aHandler::subscribe_to_task to enforce per-task
1871            // ownership MUST run before any buffered events leave the server.
1872            //
1873            // - Ok(stream): handler owns resumption (and any ACL it enforced
1874            //   passed); return its stream verbatim, matching the
1875            //   `subscribe_snapshot_then_tail` handler-override semantic.
1876            // - Err(MethodNotFound): no override, fall through to our buffer
1877            //   replay (default impl returns this).
1878            // - Any other Err: hard reject (auth failure, invalid params, etc.).
1879            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            // Subscribe to the pubsub FIRST so no events emitted during
1886            // replay are lost between replay-end and tail-subscribe.
1887            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(&params.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(_)) => { /* fall through */ }
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                // ResumeError is non_exhaustive; future variants fall through
1921                // to snapshot+tail like NotFound.
1922                Err(_) => {}
1923            }
1924        }
1925        self.subscribe_snapshot_then_tail(ctx, params, task_store)
1926            .await
1927    }
1928
1929    /// Snapshot-then-tail fallback for `SubscribeToTask`. Reads current
1930    /// task state from the store, emits a synthetic replay event, then
1931    /// tails the broadcast until the first `final_event = true`.
1932    ///
1933    /// This is the pre-resumption path extracted verbatim from the
1934    /// original `dispatch_subscribe_to_task` body.
1935    #[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(&params.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    /// Subscribe to the per-task pubsub subject and return a stream of
1970    /// `TaskEvent`s, closing after the first event with `final_event = true`.
1971    ///
1972    /// Each received [`klieo_core::Msg`] is ack-ed immediately on receipt;
1973    /// ephemeral SSE consumers prefer duplicate delivery over redelivery
1974    /// storms on disconnect. Bus errors are logged and skipped.
1975    #[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                            // Ack immediately — ephemeral SSE consumer.
2006                            if let Err(ack_err) = msg.ack.ack().await {
2007                                tracing::warn!(target: "a2a", error = %ack_err, "ack failed; message may redeliver");
2008                            }
2009                            // Extract W3C tracecontext from bus headers and
2010                            // parent the decode span under the publisher's
2011                            // span so cross-replica traces stitch into one
2012                            // tree (cluster 0.23, ADR-023).
2013                            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            // Task already in a terminal state — emit the replay
2057            // event and close; no pubsub subscription needed.
2058            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
2065/// JSON-RPC dispatcher that listens on a NATS subject.
2066pub struct A2aServer {
2067    app_prefix: String,
2068    agent_id: String,
2069    dispatcher: Arc<A2aDispatcher>,
2070    pubsub: Arc<dyn Pubsub>,
2071}
2072
2073impl A2aServer {
2074    /// Build a new server.
2075    ///
2076    /// **Authentication is mandatory** since 0.2.0. Wire a real
2077    /// [`Authenticator`] (e.g.
2078    /// `BearerTokenAuthenticator`)
2079    /// for production deployments. For tests and demos that intentionally
2080    /// run unauthenticated, pass
2081    /// `AllowAnonymous` explicitly — that
2082    /// makes the choice visible at the wiring site.
2083    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    /// Borrow the inner dispatcher (e.g. to share with an HTTP bridge
2100    /// mounted in the same process).
2101    pub fn dispatcher(&self) -> &Arc<A2aDispatcher> {
2102        &self.dispatcher
2103    }
2104
2105    /// Subject the server listens on.
2106    pub fn subject(&self) -> String {
2107        format!("{}.a2a.{}.rpc", self.app_prefix, self.agent_id)
2108    }
2109
2110    /// Run the dispatch loop until the underlying subscription terminates.
2111    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
2161/// Probe a [`klieo_core::resume::ResumeBuffer`] for the highest
2162/// retained event id. Returns `None` when the buffer has no
2163/// retained events for `stream_id` (`NotFound`) OR when the
2164/// backend errors (best-effort: log warn + skip orphan write).
2165async 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
2192/// Unwrap a `lookup_entry_with_revision` result, logging KV-layer
2193/// errors at warn before falling back to terminate. Returns
2194/// `Some((entry, rev))` only on `Ok(Some(_))`.
2195fn 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
2214/// Build the cluster-0.24 `failover-reinvoke` SSE-marker frame bytes
2215/// recorded in the resume buffer at `max + 1` when a follower
2216/// re-invokes an idempotent handler from the cached payload. The
2217/// re-invoke's own events append at `max + 2` onwards via the
2218/// existing task-store event sink, so resume clients see the marker
2219/// between the original leader's last event and the re-invoke's
2220/// fresh sequence.
2221fn 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
2241/// Build the terminal SSE-frame payload bytes that get recorded in
2242/// the resume buffer at `max_id + 1` when an orphan is detected.
2243/// The frame is the raw JSON-RPC error envelope that resume clients
2244/// see verbatim when replaying past the orphan boundary.
2245fn 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
2259/// Log an `A2aError` at the server-side seam BEFORE its wire envelope
2260/// is sent. The wire seam sanitises Display for internal classes
2261/// (`Bus`, `Server`, `Misconfigured`, `Internal`) — without this log
2262/// the source chain would never reach traces.
2263///
2264/// Logs at `warn` for client-class variants (`MethodNotFound`,
2265/// `InvalidParams`, `Unauthorized`, `TaskNotFound`,
2266/// `ResumeBufferExpired`, `Json`) and at `error` for internal-class
2267/// variants so operators can filter on the elevated level.
2268pub(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
2294/// Helper: deserialise params from raw JSON, run the handler closure,
2295/// then serialise the typed result back to a JSON [`Value`].
2296async 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        // A spec-form `message/send` request must dispatch to the exact
2376        // same `A2aHandler::send_message` call as the current-form
2377        // `SendMessage` — proving alias dispatch, not just alias parsing.
2378        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        // A scope-gated authenticator that denies the CURRENT method
2400        // name must also deny the legacy alias for the same operation
2401        // — otherwise the alias is a scope-check bypass.
2402        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        // "tasks/cancel" is the legacy alias for "CancelTask".
2434        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        // EchoHandler does not implement create_task_push_notification_config,
2479        // so a valid URL must reach the handler's own MethodNotFound —
2480        // proving the SSRF guard let it through rather than blocking it.
2481        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        // alice creates a task via the non-streaming SendMessage path.
2506        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        // A foreign tenant must NOT be able to read alice's task (CWE-639 IDOR):
2516        // deny-as-TaskNotFound, surfaced as a JSON-RPC error.
2517        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        // The owner can still read her own task.
2525        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        // Mirror of the non-streaming anon-deny: a None caller on the streaming
2594        // path must be rejected when the authenticator forbids anonymous.
2595        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        // Stream must close after the terminal replay event.
2631        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    /// Dispatcher with lenient tenant binding wired. `AllowAnonymous`
2855    /// authorizes every method, so the only gate exercised is the
2856    /// per-resource ownership check keyed off the caller in the context.
2857    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    /// Typed `SendMessageParams` for seeding a task on a handler directly,
2891    /// bypassing the (tenant-gated) dispatch path.
2892    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        // The non-streaming claim skips anonymous callers (no principal to
2918        // bind, ADR-022). An anonymous SendMessage must leave no ownership
2919        // entry — not write one under an "anonymous" sentinel.
2920        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    /// Claim ownership and RETURN the handle — the caller MUST keep it
2943    /// alive, since dropping it deletes the KV entry and reopens the gate.
2944    #[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        // Foreigner is denied — TaskNotFound (-32000), no body, never -32001.
2992        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        // Bob's cancel must be refused before the status flips.
3017        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        // Alice still sees the task in its pre-cancel state — proving the
3026        // gate ran before the handler's mutation, not after.
3027        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    /// Every KV read fails, to drive the strict `Unavailable` verdict through
3044    /// `retain_owned_tasks`.
3045    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        // Under strict tenant binding a store-down on the ownership write must
3092        // deny the create rather than leave the task unprotected (an unrecorded
3093        // task is Allowed for every tenant by `enforce_owner`).
3094        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        // Seed a task directly on the handler: the strict store-down below would
3116        // also fail the SendMessage create closed, so the seed cannot go through
3117        // dispatch — this test isolates the ListTasks (`retain_owned_tasks`) gate.
3118        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        // Each push-config method is keyed by `taskId`; a foreign principal
3159        // must be gated before the handler (deny-as-NotFound), while the owner
3160        // passes the gate and reaches the handler default (MethodNotFound on
3161        // EchoHandler). The differing codes prove the gate fires for bob only.
3162        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        // Two tasks in one handler store, one owned by each principal.
3208        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}