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    /// [`crate::auth::Authenticator::allows_anonymous`] before binding.
734    pub fn authenticator(&self) -> &Arc<dyn Authenticator> {
735        &self.authenticator
736    }
737
738    /// Get a publish wrapper for hand-emitting TaskEvents (e.g. from
739    /// `A2aTaskStore::with_event_sink`). The returned sink shares the
740    /// dispatcher's publish-concurrency semaphore, so every sink
741    /// handed out by one dispatcher is bounded by the same cap (see
742    /// [`Self::with_publish_concurrency`]).
743    pub fn event_sink(&self) -> TaskEventSink {
744        TaskEventSink::with_permits(self.pubsub.clone(), self.publish_permits.clone())
745    }
746
747    /// Borrow the configured pubsub.
748    pub fn pubsub(&self) -> &Arc<dyn klieo_core::Pubsub> {
749        &self.pubsub
750    }
751
752    /// Borrow the dispatcher's publish-concurrency semaphore. Shared
753    /// with every [`TaskEventSink`] this dispatcher hands out and
754    /// threaded into `CancelOnDrop` (private wrapper in `crate::http`) so SSE-stream
755    /// drop-time cross-replica cancel publishes bound under the same
756    /// cap as task-event fanout. See [`Self::with_publish_concurrency`]
757    /// for the dial.
758    pub fn publish_permits(&self) -> &Arc<Semaphore> {
759        &self.publish_permits
760    }
761
762    /// Borrow the per-dispatcher [`klieo_core::CancelRegistry`] keyed by
763    /// task id. The cancel-subject subscription task and the invoke
764    /// dispatch path share a single registry instance: invoke registers
765    /// a [`tokio_util::sync::CancellationToken`] under the task id at
766    /// invocation start, the subscription task fires it on inbound
767    /// `klieo.a2a.cancel.{task_id}` messages, and invoke deregisters in
768    /// a finally-style cleanup once the invocation terminates.
769    pub fn cancel_registry(&self) -> &klieo_core::CancelRegistry<String> {
770        &self.cancel_registry
771    }
772
773    /// Publish a cross-replica cancel signal for `task_id` on
774    /// `klieo.a2a.cancel.{task_id}`. Thin wrapper around
775    /// [`klieo_core::cancel::publish_cancel_signal`] — see that
776    /// helper for the full subject-validation + publish contract.
777    /// `BusError` surfaces as `A2aError::Bus(_)` via the
778    /// `#[from]` impl.
779    ///
780    /// # Security
781    /// `task_id` is validated against
782    /// [`klieo_core::validate_subject_token`] before subject
783    /// construction; metacharacters (`.`, `*`, `>`, whitespace,
784    /// non-ASCII) yield `A2aError::Bus(BusError::Invalid(_))`,
785    /// preventing a caller-controlled task id from collapsing or
786    /// wildcarding the subject namespace (CWE-74). Same guard
787    /// shape as [`TaskEventSink::send`] on the per-task event
788    /// subject.
789    ///
790    /// Cancel signals share the progressToken-as-credential threat
791    /// model documented for resume in ADR-018 / ADR-019: knowledge
792    /// of the task id grants the ability to cancel the invocation.
793    /// Operators MUST mint unguessable task ids (UUID v4 or
794    /// stronger) and gate per-tenant authorisation BEFORE the
795    /// request reaches this dispatcher; without that, cross-tenant
796    /// cancel becomes possible (CWE-639 IDOR).
797    pub async fn publish_cancel(&self, task_id: &str) -> Result<(), A2aError> {
798        klieo_core::cancel::publish_cancel_signal(&self.pubsub, A2A_CANCEL_SUBJECT_PREFIX, task_id)
799            .await?;
800        Ok(())
801    }
802
803    /// Spawn the wildcard cancel-subject background subscriber.
804    ///
805    /// Thin wrapper around
806    /// [`klieo_core::cancel::spawn_wildcard_cancel_subscriber`] —
807    /// see that helper for the loop body, ack policy, and
808    /// startup-failure semantics. Required for multi-replica
809    /// deployments; without it, only same-replica drop-cancel
810    /// works.
811    ///
812    /// Idempotent at the call-site level: calling twice spawns
813    /// two subscribers (not recommended; both will ack
814    /// independently and the bus will balance delivery).
815    pub fn with_cancel_subscription(self: &Arc<Self>) {
816        klieo_core::cancel::spawn_wildcard_cancel_subscriber(
817            self.pubsub.clone(),
818            A2A_CANCEL_SUBJECT_PATTERN.to_string(),
819            A2A_CANCEL_SUBJECT_PREFIX.to_string(),
820            self.cancel_registry.clone(),
821            A2A_CANCEL_LOG_TARGET,
822        );
823    }
824
825    /// Dispatch one non-streaming JSON-RPC request.
826    ///
827    /// This is a flat dispatch table mapping each [`A2aMethod`] variant to
828    /// its handler. Length exceeds the 40-line guideline by design —
829    /// splitting the match into helper functions fragments the routing logic
830    /// and makes it harder to see all method mappings at a glance.
831    #[allow(clippy::too_many_lines)]
832    #[instrument(
833        skip_all,
834        fields(rpc.system = "klieo-a2a", rpc.method = tracing::field::Empty),
835    )]
836    pub async fn dispatch(&self, ctx: &RequestContext, payload: &[u8]) -> JsonRpcResponse {
837        let req: JsonRpcRequest = match serde_json::from_slice(payload) {
838            Ok(r) => r,
839            Err(e) => return A2aError::Json(e).to_json_rpc_error(Value::Null),
840        };
841        tracing::Span::current().record("rpc.method", req.method.as_str());
842        let method = match A2aMethod::from_str(&req.method) {
843            Ok(m) => m,
844            Err(e) => return e.to_json_rpc_error(req.id.clone()),
845        };
846        let id = req.id.clone();
847        if let Some(identity) = ctx.caller.as_ref() {
848            if let Err(e) = self
849                .authenticator
850                .authorize_method(identity, &req.method)
851                .await
852            {
853                warn!(
854                    target: "security",
855                    error = %e,
856                    method = %req.method,
857                    "a2a authorize_method rejected",
858                );
859                return A2aError::Unauthorized(e.to_string()).to_json_rpc_error(id);
860            }
861        } else if !self.authenticator.allows_anonymous() {
862            warn!(
863                target: "security",
864                method = %req.method,
865                "a2a anonymous caller rejected (authenticator requires an identity)",
866            );
867            return A2aError::Unauthorized("anonymous caller not permitted".into())
868                .to_json_rpc_error(id);
869        }
870        let params_raw = req.params;
871        let result: Result<Value, A2aError> = match method {
872            A2aMethod::SendMessage => {
873                run_method::<SendMessageParams, _, _>(params_raw, |p| async move {
874                    let result = self.handler.send_message(ctx, p).await?;
875                    // A non-streaming SendMessage creates a task that outlives the
876                    // request and is later reached via GetTask/CancelTask/push-config
877                    // — all of which gate on `enforce_owner`. Without a persistent
878                    // ownership record an unclaimed task is Allowed for every tenant
879                    // (CWE-639 IDOR). Claim ownership before returning, mirroring the
880                    // streaming path's `try_claim_ownership`.
881                    if let SendMessageResult::Task(task) = &result {
882                        self.claim_owner_persistent(ctx, &task.id).await?;
883                    }
884                    Ok(result)
885                })
886                .await
887            }
888            // Streaming methods are only valid via dispatch_streaming;
889            // the non-streaming dispatcher returns MethodNotFound so
890            // NATS callers get a clear -32601 rather than a silent type error.
891            A2aMethod::SendStreamingMessage => {
892                Err(A2aError::MethodNotFound("SendStreamingMessage".into()))
893            }
894            A2aMethod::GetTask => {
895                run_method::<GetTaskParams, _, _>(params_raw, |p| async move {
896                    self.enforce_owner(ctx, &p.id).await?;
897                    self.handler.get_task(ctx, p).await
898                })
899                .await
900            }
901            A2aMethod::ListTasks => {
902                run_method::<ListTasksParams, _, _>(params_raw, |p| async move {
903                    let mut result = self.handler.list_tasks(ctx, p).await?;
904                    self.retain_owned_tasks(ctx, &mut result.tasks).await?;
905                    Ok(result)
906                })
907                .await
908            }
909            A2aMethod::CancelTask => {
910                run_method::<CancelTaskParams, _, _>(params_raw, |p| async move {
911                    // Gate BEFORE the handler so a foreign cancel never reaches
912                    // the cross-replica cancel publish.
913                    self.enforce_owner(ctx, &p.id).await?;
914                    self.handler.cancel_task(ctx, p).await
915                })
916                .await
917            }
918            A2aMethod::SubscribeToTask => Err(A2aError::MethodNotFound("SubscribeToTask".into())),
919            A2aMethod::CreateTaskPushNotificationConfig => {
920                run_method::<PushNotificationConfigParams, _, _>(params_raw, |p| async move {
921                    self.enforce_owner(ctx, &p.taskId).await?;
922                    self.handler
923                        .create_task_push_notification_config(ctx, p)
924                        .await
925                })
926                .await
927            }
928            A2aMethod::GetTaskPushNotificationConfig => {
929                run_method::<GetPushNotificationConfigParams, _, _>(params_raw, |p| async move {
930                    self.enforce_owner(ctx, &p.taskId).await?;
931                    self.handler.get_task_push_notification_config(ctx, p).await
932                })
933                .await
934            }
935            A2aMethod::ListTaskPushNotificationConfigs => {
936                run_method::<ListPushNotificationConfigsParams, _, _>(params_raw, |p| async move {
937                    self.enforce_owner(ctx, &p.taskId).await?;
938                    self.handler
939                        .list_task_push_notification_configs(ctx, p)
940                        .await
941                })
942                .await
943            }
944            A2aMethod::DeleteTaskPushNotificationConfig => {
945                run_method::<DeletePushNotificationConfigParams, _, _>(params_raw, |p| async move {
946                    self.enforce_owner(ctx, &p.taskId).await?;
947                    self.handler
948                        .delete_task_push_notification_config(ctx, p)
949                        .await
950                })
951                .await
952            }
953            A2aMethod::GetExtendedAgentCard => {
954                run_method::<GetExtendedAgentCardParams, _, _>(params_raw, |p| async move {
955                    self.handler.get_extended_agent_card(ctx, p).await
956                })
957                .await
958            }
959        };
960        match result {
961            Ok(value) => JsonRpcResponse {
962                jsonrpc: "2.0".into(),
963                id,
964                result: Some(value),
965                error: None,
966            },
967            Err(e) => {
968                log_internal_before_wire_seam(&e, &req.method);
969                e.to_json_rpc_error(id)
970            }
971        }
972    }
973
974    /// Handle one unverified A2A JSON-RPC request: authenticate
975    /// the caller, then dispatch on success.
976    ///
977    /// Returns a ready [`JsonRpcResponse`] in both the success and
978    /// authentication-failure cases so callers never need to branch.
979    ///
980    /// # Security
981    /// Authenticates before deserialising or dispatching
982    /// (CWE-306). On auth failure the payload never reaches the
983    /// handler; the caller receives a JSON-RPC `-32001`
984    /// envelope.
985    pub async fn handle_request(&self, headers: A2aHeaders, payload: &[u8]) -> JsonRpcResponse {
986        match self.authenticator.authenticate(&headers, payload).await {
987            Ok(identity) => {
988                let ctx = RequestContext::new(headers, Some(identity));
989                self.dispatch(&ctx, payload).await
990            }
991            Err(e) => {
992                warn!(target: "security", error = %e, "a2a auth rejected");
993                let req_id = serde_json::from_slice::<JsonRpcRequest>(payload)
994                    .map(|r| r.id)
995                    .unwrap_or(Value::Null);
996                JsonRpcResponse {
997                    jsonrpc: "2.0".into(),
998                    id: req_id,
999                    result: None,
1000                    error: Some(JsonRpcError {
1001                        code: codes::UNAUTHENTICATED,
1002                        message: "Unauthenticated".into(),
1003                        data: None,
1004                    }),
1005                }
1006            }
1007        }
1008    }
1009
1010    /// Streaming-method analog of [`Self::handle_request`].
1011    /// Authenticates first (CWE-306), constructs a [`RequestContext`],
1012    /// then delegates to [`Self::dispatch_streaming`]. Used by the HTTP
1013    /// transport for `SendStreamingMessage` and `SubscribeToTask`.
1014    ///
1015    /// # Security
1016    /// Authenticates before deserialising or dispatching (CWE-306). On
1017    /// auth failure returns [`A2aError::Unauthorized`]; the payload never
1018    /// reaches the handler.
1019    pub async fn handle_streaming(
1020        &self,
1021        headers: A2aHeaders,
1022        payload: &[u8],
1023        task_store: &crate::task_store::A2aTaskStore,
1024        cancel: tokio_util::sync::CancellationToken,
1025        last_event_id: Option<u64>,
1026        resume_buffer: Arc<dyn klieo_core::resume::ResumeBuffer>,
1027    ) -> Result<TaskEventStream, A2aError> {
1028        match self.authenticator.authenticate(&headers, payload).await {
1029            Ok(identity) => {
1030                let ctx = RequestContext::new(headers, Some(identity)).with_cancel(cancel);
1031                self.dispatch_streaming(&ctx, payload, task_store, last_event_id, resume_buffer)
1032                    .await
1033            }
1034            Err(e) => {
1035                warn!(target: "security", error = %e, "a2a auth rejected (streaming path)");
1036                Err(A2aError::Unauthorized(e.to_string()))
1037            }
1038        }
1039    }
1040
1041    /// Dispatch a streaming method (`SendStreamingMessage` or
1042    /// `SubscribeToTask`). Returns a [`TaskEventStream`] the HTTP
1043    /// transport serialises as SSE frames.
1044    ///
1045    /// The handler is tried first. On `Ok(stream)` the dispatcher
1046    /// returns it unchanged (handler override). On
1047    /// `Err(MethodNotFound)` the dispatcher synthesises a
1048    /// broadcast-derived stream:
1049    /// - `SendStreamingMessage`: converts params, calls
1050    ///   `send_message`, then tails the broadcast filtered by the
1051    ///   returned task id.
1052    /// - `SubscribeToTask`: reads current state from `task_store`,
1053    ///   emits a synthetic replay event, then tails the broadcast
1054    ///   until the first `final_event = true`.
1055    ///
1056    /// # Security
1057    /// Requires a pre-authenticated [`RequestContext`]. Callers
1058    /// must authenticate before invoking this method (the NATS
1059    /// path's [`A2aDispatcher::handle_request`] does this; an
1060    /// HTTP caller must authenticate at the request boundary).
1061    /// Passing an unauthenticated context violates CWE-306.
1062    #[instrument(
1063        skip_all,
1064        fields(rpc.system = "klieo-a2a", rpc.method = tracing::field::Empty),
1065        err,
1066    )]
1067    pub async fn dispatch_streaming(
1068        &self,
1069        ctx: &RequestContext,
1070        payload: &[u8],
1071        task_store: &crate::task_store::A2aTaskStore,
1072        last_event_id: Option<u64>,
1073        resume_buffer: Arc<dyn klieo_core::resume::ResumeBuffer>,
1074    ) -> Result<TaskEventStream, A2aError> {
1075        let req: JsonRpcRequest =
1076            serde_json::from_slice(payload).map_err(|e| A2aError::InvalidParams(e.to_string()))?;
1077        tracing::Span::current().record("rpc.method", req.method.as_str());
1078        let method = A2aMethod::from_str(&req.method)
1079            .map_err(|_| A2aError::MethodNotFound(req.method.clone()))?;
1080        if let Some(identity) = ctx.caller.as_ref() {
1081            if let Err(e) = self
1082                .authenticator
1083                .authorize_method(identity, &req.method)
1084                .await
1085            {
1086                warn!(
1087                    target: "security",
1088                    error = %e,
1089                    method = %req.method,
1090                    "a2a authorize_method rejected (streaming path)",
1091                );
1092                return Err(A2aError::Unauthorized(e.to_string()));
1093            }
1094        } else if !self.authenticator.allows_anonymous() {
1095            warn!(
1096                target: "security",
1097                method = %req.method,
1098                "a2a anonymous caller rejected (streaming path; authenticator requires an identity)",
1099            );
1100            return Err(A2aError::Unauthorized(
1101                "anonymous caller not permitted".into(),
1102            ));
1103        }
1104        let params_raw = req.params;
1105        match method {
1106            A2aMethod::SendStreamingMessage => {
1107                let params: SendMessageParams = serde_json::from_value(params_raw)
1108                    .map_err(|e| A2aError::InvalidParams(e.to_string()))?;
1109                // Resumption is subscribe-only in v1 — SendStreamingMessage
1110                // ignores last_event_id and resume_buffer.
1111                self.dispatch_send_streaming(ctx, params, task_store).await
1112            }
1113            A2aMethod::SubscribeToTask => {
1114                let params: SubscribeToTaskParams = serde_json::from_value(params_raw)
1115                    .map_err(|e| A2aError::InvalidParams(e.to_string()))?;
1116                self.dispatch_subscribe_to_task(
1117                    ctx,
1118                    params,
1119                    task_store,
1120                    last_event_id,
1121                    resume_buffer,
1122                )
1123                .await
1124            }
1125            _ => Err(A2aError::MethodNotFound(req.method)),
1126        }
1127    }
1128
1129    #[instrument(
1130        skip_all,
1131        fields(
1132            rpc.system = "klieo-a2a",
1133            rpc.method = "SendStreamingMessage",
1134            klieo.stream_id = tracing::field::Empty,
1135        ),
1136        err,
1137    )]
1138    async fn dispatch_send_streaming(
1139        &self,
1140        ctx: &RequestContext,
1141        params: SendMessageParams,
1142        task_store: &crate::task_store::A2aTaskStore,
1143    ) -> Result<TaskEventStream, A2aError> {
1144        self.run_streaming_invoke(ctx, params, task_store, None)
1145            .await
1146    }
1147
1148    /// Common body for `dispatch_send_streaming` and the cluster-0.24
1149    /// follower re-invoke path. When `existing_leader` is `Some`, skips
1150    /// the on-invoke leader claim and adopts the caller-provided handle
1151    /// (which already encodes attempt+1 and the cached payload via the
1152    /// follower's CAS claim). When `None`, captures the original params
1153    /// + principal and claims a fresh leader entry.
1154    async fn run_streaming_invoke(
1155        &self,
1156        ctx: &RequestContext,
1157        params: SendMessageParams,
1158        task_store: &crate::task_store::A2aTaskStore,
1159        existing_leader: Option<klieo_core::LeaderHandle>,
1160    ) -> Result<TaskEventStream, A2aError> {
1161        match self
1162            .handler
1163            .send_streaming_message(ctx, params.clone())
1164            .await
1165        {
1166            Ok(stream) => return Ok(stream),
1167            Err(A2aError::MethodNotFound(_)) => {}
1168            Err(other) => return Err(other),
1169        }
1170        // Capture the original params for cluster-0.24 failover
1171        // re-invoke BEFORE consuming `params` in the synthetic
1172        // fallback below. A serialisation failure here is non-fatal:
1173        // the invoke proceeds with `payload = None` and any follower
1174        // that detects an orphan will terminate (no payload to
1175        // re-invoke from).
1176        let payload_bytes_for_failover = match existing_leader {
1177            Some(_) => None,
1178            None => match serde_json::to_vec(&params) {
1179                Ok(v) => Some(Bytes::from(v)),
1180                Err(e) => {
1181                    warn!(
1182                        target: "a2a.failover",
1183                        error = %e,
1184                        "send-streaming params serialise for failover failed; \
1185                         proceeding without cached payload",
1186                    );
1187                    None
1188                }
1189            },
1190        };
1191        let principal_for_failover = match existing_leader {
1192            Some(_) => None,
1193            None => ctx
1194                .caller
1195                .as_ref()
1196                .filter(|id| !id.is_anonymous())
1197                .map(|id| id.as_str().to_string()),
1198        };
1199        let result = self.handler.send_message(ctx, params).await?;
1200        let task = match result {
1201            SendMessageResult::Task(t) => t,
1202            SendMessageResult::Message(_) => {
1203                // Non-task result — no task id to tail; return an
1204                // immediately-closed stream.
1205                return Ok(Box::pin(futures::stream::empty()));
1206            }
1207        };
1208        let task_id = task.id.clone();
1209        tracing::Span::current().record("klieo.stream_id", task_id.as_str());
1210        let terminal = task.status.is_terminal();
1211        let event_id = task_store.next_event_id(&task_id);
1212        let initial = TaskEvent::new(
1213            task_id.clone(),
1214            task.status,
1215            task.history.last().cloned(),
1216            terminal,
1217        )
1218        .with_event_id(event_id);
1219        if terminal {
1220            // Task already in a terminal state — emit the synthetic event
1221            // and close. No pubsub subscription needed, and no risk of
1222            // hanging when the handler (e.g. EchoHandler) doesn't write
1223            // through to a store.
1224            return Ok(Box::pin(futures::stream::once(futures::future::ready(
1225                initial,
1226            ))));
1227        }
1228        let initial_stream = futures::stream::once(futures::future::ready(initial));
1229        let tail = self.task_event_stream(task_id.clone()).await?;
1230        let chained = futures::StreamExt::chain(initial_stream, tail);
1231        // Adopt the follower-supplied handle on cluster-0.24
1232        // re-invoke; otherwise claim fresh leadership for the
1233        // lifetime of the SSE response (multi-replica orphan
1234        // detection, ADR-020). KV-layer failures on a fresh claim
1235        // degrade silently: log warn + proceed without a claim.
1236        let leader_handle = match existing_leader {
1237            Some(handle) => Some(handle),
1238            None => {
1239                self.try_claim_leader(&task_id, payload_bytes_for_failover, principal_for_failover)
1240                    .await
1241            }
1242        };
1243        let ownership_handle = self.try_claim_ownership(ctx, &task_id).await?;
1244        Ok(Box::pin(LeaderHoldStream {
1245            inner: chained,
1246            _leader: leader_handle,
1247            _ownership: ownership_handle,
1248        }))
1249    }
1250
1251    /// Tenant-ownership claim for a streaming invoke. Returns `Ok(None)` (no
1252    /// claim, proceed) when no registry is wired, or the caller is anonymous
1253    /// (no principal to bind). On KV `put` failure the lenient registry returns
1254    /// `Ok(None)` (fail-open per ADR-022; the later `SubscribeToTask` lookup sees
1255    /// no entry and proceeds unbound), while a **strict** registry returns
1256    /// `Err` so the invoke is denied rather than started unprotected.
1257    ///
1258    /// The returned `OwnershipHandle` MUST be kept alive for the lifetime of the
1259    /// SSE response; the `LeaderHoldStream` wrapper carries it to drop-time so
1260    /// the `kv.delete` fires on stream end.
1261    async fn try_claim_ownership(
1262        &self,
1263        ctx: &RequestContext,
1264        task_id: &str,
1265    ) -> Result<Option<klieo_core::OwnershipHandle>, A2aError> {
1266        let Some(registry) = self.ownership_registry.as_ref() else {
1267            return Ok(None);
1268        };
1269        let Some(caller) = ctx.caller.as_ref() else {
1270            return Ok(None);
1271        };
1272        if caller.is_anonymous() {
1273            return Ok(None);
1274        }
1275        let key = ownership_key(task_id);
1276        let principal = caller.as_str().to_string();
1277        match registry.claim_guarded(key, principal).await {
1278            klieo_core::OwnershipClaim::Claimed(handle) => Ok(Some(handle)),
1279            klieo_core::OwnershipClaim::Proceed => Ok(None),
1280            klieo_core::OwnershipClaim::Unavailable => Err(A2aError::Server(
1281                "ownership store unavailable; invoke denied (strict tenant binding)".into(),
1282            )),
1283            // `OwnershipClaim` is non_exhaustive: an unrecognised future outcome
1284            // is treated as deny — the fail-closed default for a security gate.
1285            _ => Err(A2aError::Server(
1286                "ownership claim inconclusive; invoke denied".into(),
1287            )),
1288        }
1289    }
1290
1291    /// Record persistent tenant ownership for a task created by a non-streaming
1292    /// invoke. The streaming-path analogue [`Self::try_claim_ownership`] holds a
1293    /// drop-guard for the SSE lifetime; here the task outlives the request, so
1294    /// the entry must persist (reclaimed by the tenants-bucket reaper/TTL). No
1295    /// registry / no caller / anonymous caller → `Ok(())` (partial-deployment
1296    /// skip, ADR-022). On a strict-registry store error the invoke is denied so
1297    /// the task is never left readable by every tenant via `enforce_owner`.
1298    async fn claim_owner_persistent(
1299        &self,
1300        ctx: &RequestContext,
1301        task_id: &str,
1302    ) -> Result<(), A2aError> {
1303        let Some(registry) = self.ownership_registry.as_ref() else {
1304            return Ok(());
1305        };
1306        let Some(caller) = ctx.caller.as_ref() else {
1307            return Ok(());
1308        };
1309        if caller.is_anonymous() {
1310            return Ok(());
1311        }
1312        let key = ownership_key(task_id);
1313        match registry
1314            .record_owner(key, caller.as_str().to_string())
1315            .await
1316        {
1317            klieo_core::OwnershipRecord::Recorded | klieo_core::OwnershipRecord::Proceed => Ok(()),
1318            klieo_core::OwnershipRecord::Unavailable => Err(A2aError::Server(
1319                "ownership store unavailable; invoke denied (strict tenant binding)".into(),
1320            )),
1321            // `OwnershipRecord` is non_exhaustive: an unrecognised future outcome
1322            // is treated as deny — the fail-closed default for a security gate.
1323            _ => Err(A2aError::Server(
1324                "ownership record inconclusive; invoke denied".into(),
1325            )),
1326        }
1327    }
1328
1329    /// SubscribeToTask tenant-binding gate. Looks up the owner in the
1330    /// `klieo-tenants` bucket and matches against the caller's
1331    /// principal.
1332    ///
1333    /// - Owner match → `Ok(())` (proceed).
1334    /// - Owner mismatch → `Err(A2aError::TaskNotFound)` — deny-as-
1335    ///   NotFound per OWASP IDOR best practice; same wire shape as a
1336    ///   nonexistent task so the response leaks no existence info.
1337    /// - No registry / no caller / anonymous caller → `Ok(())`
1338    ///   (partial-deployment skip, ADR-022).
1339    /// - Missing entry (`Ok(None)`) → `Ok(())` (legacy pre-0.22
1340    ///   stream or no auth wired at invoke).
1341    /// - KV lookup error → `Ok(())` (fail-open; same posture as the
1342    ///   leader is_alive probe in ADR-020).
1343    async fn enforce_owner(&self, ctx: &RequestContext, task_id: &str) -> Result<(), A2aError> {
1344        let Some(registry) = self.ownership_registry.as_ref() else {
1345            return Ok(());
1346        };
1347        let Some(caller) = ctx.caller.as_ref() else {
1348            return Ok(());
1349        };
1350        if caller.is_anonymous() {
1351            return Ok(());
1352        }
1353        let key = ownership_key(task_id);
1354        match registry.check_owner(&key, caller.as_str()).await {
1355            klieo_core::OwnershipCheck::Allowed => Ok(()),
1356            klieo_core::OwnershipCheck::Denied => {
1357                // Mismatch — deny-as-NotFound. No log of the claimed owner;
1358                // just the rejected principal so operators can spot IDOR
1359                // probes without leaking tenancy across the wire.
1360                warn!(
1361                    target: "a2a.tenants",
1362                    task_id = %task_id,
1363                    principal = %caller.as_str(),
1364                    "ownership mismatch on SubscribeToTask; denying as TaskNotFound",
1365                );
1366                Err(A2aError::TaskNotFound(task_id.to_string()))
1367            }
1368            klieo_core::OwnershipCheck::Unavailable => {
1369                warn!(
1370                    target: "a2a.tenants",
1371                    task_id = %task_id,
1372                    principal = %caller.as_str(),
1373                    "ownership store unavailable; request denied (strict tenant binding)",
1374                );
1375                Err(A2aError::OwnershipUnavailable)
1376            }
1377            other => {
1378                warn!(
1379                    target: "a2a.tenants",
1380                    task_id = %task_id,
1381                    principal = %caller.as_str(),
1382                    verdict = ?other,
1383                    "inconclusive ownership verdict; request denied",
1384                );
1385                Err(A2aError::OwnershipUnavailable)
1386            }
1387        }
1388    }
1389
1390    /// Filter a `ListTasks` page down to the tasks the caller owns.
1391    ///
1392    /// Per-task analogue of [`Self::enforce_owner`]; same partial-deployment
1393    /// skip (no registry / no caller / anonymous → no filtering, ADR-022).
1394    /// A `Denied` task is dropped silently so the page cannot become a
1395    /// cross-tenant enumeration oracle (OWASP IDOR); an `Unavailable` verdict
1396    /// fails the whole list closed.
1397    ///
1398    /// [`klieo_core::KvStore`] has no multi-get, so the per-task checks are
1399    /// issued concurrently (one fan-out, ~one round-trip of latency) rather
1400    /// than serially — a page of N tasks must not stall on N sequential
1401    /// round-trips.
1402    async fn retain_owned_tasks(
1403        &self,
1404        ctx: &RequestContext,
1405        tasks: &mut Vec<Task>,
1406    ) -> Result<(), A2aError> {
1407        let Some(registry) = self.ownership_registry.as_ref() else {
1408            return Ok(());
1409        };
1410        let Some(caller) = ctx.caller.as_ref() else {
1411            return Ok(());
1412        };
1413        if caller.is_anonymous() {
1414            return Ok(());
1415        }
1416        let verdicts = futures::future::join_all(tasks.iter().map(|task| {
1417            let key = ownership_key(&task.id);
1418            async move { registry.check_owner(&key, caller.as_str()).await }
1419        }))
1420        .await;
1421
1422        let mut owned = Vec::with_capacity(tasks.len());
1423        for (task, verdict) in std::mem::take(tasks).into_iter().zip(verdicts) {
1424            match verdict {
1425                klieo_core::OwnershipCheck::Allowed => owned.push(task),
1426                klieo_core::OwnershipCheck::Denied => {}
1427                klieo_core::OwnershipCheck::Unavailable => {
1428                    warn!(
1429                        target: "a2a.tenants",
1430                        principal = %caller.as_str(),
1431                        "ownership store unavailable; ListTasks denied (strict tenant binding)",
1432                    );
1433                    return Err(A2aError::OwnershipUnavailable);
1434                }
1435                other => {
1436                    warn!(
1437                        target: "a2a.tenants",
1438                        principal = %caller.as_str(),
1439                        verdict = ?other,
1440                        "inconclusive ownership verdict; ListTasks denied",
1441                    );
1442                    return Err(A2aError::OwnershipUnavailable);
1443                }
1444            }
1445        }
1446        *tasks = owned;
1447        Ok(())
1448    }
1449
1450    /// Best-effort leader claim for a streaming invoke. Returns
1451    /// `None` when no registry is wired or when the KV `put` fails
1452    /// (fail-open per ADR-020). The returned `LeaderHandle` MUST be
1453    /// kept alive for the lifetime of the SSE response; the
1454    /// `LeaderHoldStream` wrapper carries it to drop-time.
1455    ///
1456    /// `payload` carries the original invoke params serialised to
1457    /// JSON bytes so a follower that detects an orphan can re-invoke
1458    /// an idempotent handler from the cached payload (cluster 0.24).
1459    /// `principal` records the authenticated subject (cluster 0.21)
1460    /// so the re-invoke runs under the same tenant binding as the
1461    /// original.
1462    async fn try_claim_leader(
1463        &self,
1464        task_id: &str,
1465        payload: Option<Bytes>,
1466        principal: Option<String>,
1467    ) -> Option<klieo_core::LeaderHandle> {
1468        let registry = self.leader_registry.as_ref()?;
1469        let key = format!("{A2A_LEADER_KEY_PREFIX}{task_id}");
1470        match registry
1471            .claim_with_heartbeat(
1472                key,
1473                self.leader_ttl,
1474                self.leader_heartbeat_interval,
1475                payload,
1476                principal,
1477            )
1478            .await
1479        {
1480            Ok(handle) => Some(handle),
1481            Err(e) => {
1482                warn!(
1483                    target: "a2a.leader",
1484                    task_id = %task_id,
1485                    error = %e,
1486                    "leader claim failed; degrading to no-claim (orphan detection \
1487                     disabled for this stream)",
1488                );
1489                None
1490            }
1491        }
1492    }
1493
1494    /// Orphan recovery: on a dead leader probe, write a terminal
1495    /// "leader died" SSE frame at `max_id + 1` to the resume buffer
1496    /// and mark the buffer terminal. Future resume clients replaying
1497    /// past `max_id` see the synthetic frame and close.
1498    ///
1499    /// Returns `Some(())` when the orphan write landed (caller raises
1500    /// [`A2aError::LeaderDied`] to terminate the current request);
1501    /// `None` when:
1502    /// - The buffer has no retained events (leader never claimed +
1503    ///   never wrote → not an orphan, just a non-streaming client).
1504    ///   Caller falls through to the regular snapshot+tail path.
1505    /// - The terminal-frame `record` failed (best-effort: log warn +
1506    ///   fall through to the regular subscribe path so a transient
1507    ///   KV blip does not surface as a hard-fail to the client).
1508    async fn write_orphan_terminal_frame(
1509        &self,
1510        task_id: &str,
1511        resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
1512    ) -> Option<()> {
1513        let max_id = max_event_id(resume_buffer, task_id).await?;
1514        let next_id = max_id + 1;
1515        let frame = leader_died_sse_frame_bytes(task_id, next_id);
1516        if let Err(e) = resume_buffer.record(task_id, next_id, frame).await {
1517            warn!(
1518                target: "a2a.leader",
1519                task_id = %task_id,
1520                next_id,
1521                error = %e,
1522                "orphan terminal record failed; skipping orphan write",
1523            );
1524            return None;
1525        }
1526        if let Err(e) = resume_buffer.close(task_id).await {
1527            warn!(
1528                target: "a2a.leader",
1529                task_id = %task_id,
1530                error = %e,
1531                "orphan terminal close failed; resume buffer may retain stale stream",
1532            );
1533        }
1534        tracing::error!(
1535            target: "a2a.leader",
1536            task_id = %task_id,
1537            next_id,
1538            "stream leader died; emitted LEADER_DIED terminal frame at max+1",
1539        );
1540        Some(())
1541    }
1542
1543    /// Cluster-0.24 follower-side response to a dead-leader probe.
1544    /// Returns:
1545    /// - `Ok(Some(stream))` when the follower re-invoked an idempotent
1546    ///   handler from the cached payload — the returned stream owns
1547    ///   the new leader claim via `LeaderHoldStream`.
1548    /// - `Ok(None)` when the follower wrote the terminal "leader died"
1549    ///   frame OR no orphan was detected (no resume-buffer entries) —
1550    ///   caller falls through to the regular snapshot+tail path on
1551    ///   `None`-from-no-orphan and the `LeaderDied` error path is
1552    ///   embedded as an `Err` returned from this method on terminate.
1553    /// - `Err(A2aError::LeaderDied { .. })` when the terminate-only
1554    ///   path fired (cluster-0.20 fallback).
1555    ///
1556    /// Public under `test-fixtures` ONLY so cluster-0.24 integration
1557    /// tests can drive the gate directly. Production callers reach
1558    /// this method via `dispatch_subscribe_to_task` after a
1559    /// `LeaderProbe::Dead` probe; tests bypass the probe because the
1560    /// probe and `lookup_entry_with_revision` both go through the
1561    /// same `kv.get` (an entry either exists or doesn't — there is
1562    /// no production window in which one probe sees None and the
1563    /// other sees Some, so an end-to-end HTTP-driven test that mocks
1564    /// "dead leader" via `kv.delete` collapses the entry visibility
1565    /// for the lookup too).
1566    #[cfg(not(feature = "test-fixtures"))]
1567    async fn handle_dead_leader_orphan(
1568        &self,
1569        ctx: &RequestContext,
1570        task_id: &str,
1571        task_store: &crate::task_store::A2aTaskStore,
1572        resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
1573    ) -> Result<Option<TaskEventStream>, A2aError> {
1574        self.handle_dead_leader_orphan_impl(ctx, task_id, task_store, resume_buffer)
1575            .await
1576    }
1577
1578    /// Test-only public alias for the cluster-0.24 orphan re-invoke
1579    /// gate. See the doc above for why integration tests reach the
1580    /// gate directly instead of driving through `is_alive`.
1581    #[cfg(feature = "test-fixtures")]
1582    pub async fn handle_dead_leader_orphan(
1583        &self,
1584        ctx: &RequestContext,
1585        task_id: &str,
1586        task_store: &crate::task_store::A2aTaskStore,
1587        resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
1588    ) -> Result<Option<TaskEventStream>, A2aError> {
1589        self.handle_dead_leader_orphan_impl(ctx, task_id, task_store, resume_buffer)
1590            .await
1591    }
1592
1593    async fn handle_dead_leader_orphan_impl(
1594        &self,
1595        ctx: &RequestContext,
1596        task_id: &str,
1597        task_store: &crate::task_store::A2aTaskStore,
1598        resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
1599    ) -> Result<Option<TaskEventStream>, A2aError> {
1600        let registry = match self.leader_registry.as_ref() {
1601            Some(reg) => reg,
1602            None => return Ok(None),
1603        };
1604        let key = format!("{A2A_LEADER_KEY_PREFIX}{task_id}");
1605        let lookup = registry.lookup_entry_with_revision(&key).await;
1606        let Some((entry, prior_rev)) = lookup_ok_or_log(&key, lookup) else {
1607            // No readable entry (Ok(None) or KV error) — fall back to
1608            // terminate-only so the cluster-0.20 contract still holds.
1609            return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1610        };
1611        if !self.handler.is_idempotent() {
1612            tracing::debug!(
1613                target: "a2a.failover",
1614                task_id,
1615                "handler not idempotent; emitting terminate frame",
1616            );
1617            return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1618        }
1619        if entry.attempt >= self.max_failover_attempts {
1620            tracing::warn!(
1621                target: "a2a.failover",
1622                task_id,
1623                attempt = entry.attempt,
1624                cap = self.max_failover_attempts,
1625                "failover attempt cap reached; emitting terminate frame",
1626            );
1627            return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1628        }
1629        let Some(payload_bytes) = entry.payload.clone() else {
1630            tracing::debug!(
1631                target: "a2a.failover",
1632                task_id,
1633                "no cached payload on leader entry; emitting terminate frame",
1634            );
1635            return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1636        };
1637        let new_handle = match registry
1638            .claim_with_attempt_cas_and_heartbeat(
1639                key.clone(),
1640                self.leader_ttl,
1641                self.leader_heartbeat_interval,
1642                prior_rev,
1643                &entry,
1644            )
1645            .await
1646        {
1647            Ok(h) => h,
1648            Err(klieo_core::BusError::CasConflict { .. }) => {
1649                tracing::info!(
1650                    target: "a2a.failover",
1651                    task_id,
1652                    "another follower won the CAS race; emitting terminate frame",
1653                );
1654                return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1655            }
1656            Err(e) => {
1657                tracing::warn!(
1658                    target: "a2a.failover",
1659                    task_id,
1660                    error = %e,
1661                    "CAS claim failed; emitting terminate frame",
1662                );
1663                return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1664            }
1665        };
1666        self.record_failover_marker(task_id, &entry, &new_handle, resume_buffer)
1667            .await;
1668        let parsed_params: SendMessageParams = match serde_json::from_slice(&payload_bytes) {
1669            Ok(p) => p,
1670            Err(e) => {
1671                tracing::error!(
1672                    target: "a2a.failover",
1673                    task_id,
1674                    error = %e,
1675                    "cached payload parse failed; emitting terminate frame",
1676                );
1677                return self.terminate_orphan_a2a(task_id, resume_buffer).await;
1678            }
1679        };
1680        let reinvoke_ctx = self.fabricate_reinvoke_ctx(ctx, entry.principal.as_deref());
1681        let stream = self
1682            .run_streaming_invoke(&reinvoke_ctx, parsed_params, task_store, Some(new_handle))
1683            .await?;
1684        Ok(Some(stream))
1685    }
1686
1687    /// Write the cluster-0.24 `failover-reinvoke` marker frame to the
1688    /// resume buffer at `max+1`. Best-effort: a record failure logs a
1689    /// warn + continues so the re-invoke still drives the production
1690    /// stream.
1691    async fn record_failover_marker(
1692        &self,
1693        task_id: &str,
1694        prior: &klieo_core::LeaderEntry,
1695        new_handle: &klieo_core::LeaderHandle,
1696        resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
1697    ) {
1698        let Some(max) = max_event_id(resume_buffer, task_id).await else {
1699            // No retained events — re-invoke writes at event_id 1 onwards
1700            // via the regular task_store path; no marker frame needed.
1701            return;
1702        };
1703        let marker_id = max + 1;
1704        let frame = failover_reinvoke_sse_frame_bytes(
1705            task_id,
1706            marker_id,
1707            prior.attempt + 1,
1708            new_handle.replica_id(),
1709        );
1710        if let Err(e) = resume_buffer.record(task_id, marker_id, frame).await {
1711            warn!(
1712                target: "a2a.failover",
1713                task_id,
1714                marker_id,
1715                error = %e,
1716                "failover-reinvoke marker record failed; continuing without marker",
1717            );
1718        }
1719    }
1720
1721    /// Build a fresh [`RequestContext`] for the failover re-invoke.
1722    /// The original `ctx.headers` are reused so handlers that read
1723    /// transport metadata see the resume request's framing; the
1724    /// caller identity is replaced with the cached principal so the
1725    /// re-invoke runs under the original tenant binding rather than
1726    /// the resume client's identity (cluster 0.22).
1727    fn fabricate_reinvoke_ctx(
1728        &self,
1729        ctx: &RequestContext,
1730        principal: Option<&str>,
1731    ) -> RequestContext {
1732        let caller = principal
1733            .map(|p| klieo_auth_common::Identity::new(p.to_string()))
1734            .or_else(|| Some(klieo_auth_common::Identity::anonymous()));
1735        RequestContext::new(ctx.headers.clone(), caller).with_cancel(ctx.cancel.clone())
1736    }
1737
1738    /// Terminate-only orphan response (cluster 0.20 path). Writes the
1739    /// "leader died" frame to the resume buffer + raises
1740    /// [`A2aError::LeaderDied`]. Returns `Ok(None)` (caller falls
1741    /// through to the regular subscribe path) when no buffer entries
1742    /// exist — same shape as the inline pre-0.24 logic.
1743    async fn terminate_orphan_a2a(
1744        &self,
1745        task_id: &str,
1746        resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
1747    ) -> Result<Option<TaskEventStream>, A2aError> {
1748        if self
1749            .write_orphan_terminal_frame(task_id, resume_buffer)
1750            .await
1751            .is_some()
1752        {
1753            return Err(A2aError::LeaderDied {
1754                stream_id: format!("{A2A_LEADER_KEY_PREFIX}{task_id}"),
1755            });
1756        }
1757        Ok(None)
1758    }
1759
1760    /// Best-effort leader-alive probe for a SubscribeToTask entry.
1761    ///
1762    /// Returns `LeaderProbe::NoRegistry` when leader election is not
1763    /// wired; otherwise probes the KV. `Err(_)` from the registry
1764    /// fails open (treat as alive) per the ADR-020 posture: a
1765    /// transient KV blip must NOT trip an orphan write that would
1766    /// terminate a live stream prematurely.
1767    async fn probe_leader(&self, task_id: &str) -> LeaderProbe {
1768        let Some(registry) = self.leader_registry.as_ref() else {
1769            return LeaderProbe::NoRegistry;
1770        };
1771        let key = format!("{A2A_LEADER_KEY_PREFIX}{task_id}");
1772        match registry.is_alive(&key).await {
1773            Ok(true) => LeaderProbe::Alive,
1774            Ok(false) => LeaderProbe::Dead,
1775            Err(e) => {
1776                warn!(
1777                    target: "a2a.leader",
1778                    task_id = %task_id,
1779                    error = %e,
1780                    "is_alive probe failed; treating as alive (fail-open per ADR-020)",
1781                );
1782                LeaderProbe::Alive
1783            }
1784        }
1785    }
1786
1787    /// # Authorization
1788    ///
1789    /// The synthetic fallback path (when the handler returns
1790    /// `MethodNotFound`) reads the task from [`crate::task_store::A2aTaskStore`]
1791    /// keyed by `params.id` without a per-task ACL check. Task IDs are
1792    /// UUID v4 (unguessable) by convention, but adopters that need
1793    /// per-task ownership enforcement should override
1794    /// [`A2aHandler::subscribe_to_task`] and perform the check before
1795    /// returning the stream.
1796    ///
1797    /// When `last_event_id` is set and the `resume_buffer` has retained
1798    /// events for the task, events since that id are replayed first, then
1799    /// the live broadcast tail is appended (with dedup by `event_id` to
1800    /// handle the subscribe-before-replay race).  Falls back to
1801    /// `subscribe_snapshot_then_tail` when the buffer returns `NotFound`
1802    /// or a backend error.  Returns [`A2aError::ResumeBufferExpired`] when
1803    /// the cursor predates the oldest retained event.
1804    #[instrument(
1805        skip_all,
1806        fields(
1807            rpc.system = "klieo-a2a",
1808            rpc.method = "SubscribeToTask",
1809            klieo.stream_id = %params.id,
1810        ),
1811        err,
1812    )]
1813    async fn dispatch_subscribe_to_task(
1814        &self,
1815        ctx: &RequestContext,
1816        params: SubscribeToTaskParams,
1817        task_store: &crate::task_store::A2aTaskStore,
1818        last_event_id: Option<u64>,
1819        resume_buffer: Arc<dyn klieo_core::resume::ResumeBuffer>,
1820    ) -> Result<TaskEventStream, A2aError> {
1821        // Tenant binding gate (ADR-022). Runs BEFORE the leader probe
1822        // + task_store lookup so a cross-tenant probe cannot
1823        // distinguish "exists but owned by someone else" from
1824        // "doesn't exist" via timing or downstream errors. On
1825        // mismatch returns TaskNotFound — sanitised on the HTTP wire
1826        // by `dispatch_streaming` into the same `-32000 internal
1827        // server error` envelope as the nonexistent-task path so the
1828        // response leaks no existence info (OWASP IDOR).
1829        self.enforce_owner(ctx, &params.id).await?;
1830        // Orphan detection: leader probe at the SubscribeToTask entry.
1831        // When the leader is dead AND the resume buffer has retained
1832        // events (the leader must have written some before dying), the
1833        // follower either re-invokes from the cached payload (cluster
1834        // 0.24, idempotent handler + attempt cap + payload available)
1835        // OR writes the terminal "leader died" SSE frame and raises
1836        // [`A2aError::LeaderDied`] (cluster 0.20 fallback).
1837        if let LeaderProbe::Dead = self.probe_leader(&params.id).await {
1838            if let Some(stream) = self
1839                .handle_dead_leader_orphan(ctx, &params.id, task_store, resume_buffer.as_ref())
1840                .await?
1841            {
1842                return Ok(stream);
1843            }
1844        }
1845        if let Some(since) = last_event_id {
1846            // Gate the resume branch through the handler's ACL: adopters that
1847            // override A2aHandler::subscribe_to_task to enforce per-task
1848            // ownership MUST run before any buffered events leave the server.
1849            //
1850            // - Ok(stream): handler owns resumption (and any ACL it enforced
1851            //   passed); return its stream verbatim, matching the
1852            //   `subscribe_snapshot_then_tail` handler-override semantic.
1853            // - Err(MethodNotFound): no override, fall through to our buffer
1854            //   replay (default impl returns this).
1855            // - Any other Err: hard reject (auth failure, invalid params, etc.).
1856            match self.handler.subscribe_to_task(ctx, params.clone()).await {
1857                Ok(stream) => return Ok(stream),
1858                Err(A2aError::MethodNotFound(_)) => {}
1859                Err(other) => return Err(other),
1860            }
1861
1862            // Subscribe to the pubsub FIRST so no events emitted during
1863            // replay are lost between replay-end and tail-subscribe.
1864            let task_id_for_tail = params.id.clone();
1865            let tail_stream = self.task_event_stream(task_id_for_tail).await?;
1866
1867            match resume_buffer.replay(&params.id, since).await {
1868                Ok(replay_stream) => {
1869                    use std::sync::atomic::{AtomicU64, Ordering};
1870                    use std::sync::Arc as StdArc;
1871
1872                    let max_replayed = StdArc::new(AtomicU64::new(since));
1873                    let max_for_replay = max_replayed.clone();
1874                    let task_id_for_replay = params.id.clone();
1875                    let replay = replay_stream.map(move |(id, bytes)| {
1876                        max_for_replay.fetch_max(id, Ordering::SeqCst);
1877                        serde_json::from_slice::<TaskEvent>(&bytes)
1878                            .unwrap_or_else(|_| TaskEvent::synthetic_corrupt(&task_id_for_replay))
1879                    });
1880                    let max_for_tail = max_replayed.clone();
1881                    let tail = tail_stream.filter(move |ev: &TaskEvent| {
1882                        ev.event_id > max_for_tail.load(Ordering::SeqCst)
1883                    });
1884                    return Ok(Box::pin(futures::StreamExt::chain(replay, tail)));
1885                }
1886                Err(klieo_core::resume::ResumeError::Expired { since_id }) => {
1887                    return Err(A2aError::ResumeBufferExpired { since_id });
1888                }
1889                Err(klieo_core::resume::ResumeError::NotFound(_)) => { /* fall through */ }
1890                Err(klieo_core::resume::ResumeError::Backend(e)) => {
1891                    tracing::warn!(
1892                        target: "a2a.resume",
1893                        error = %e,
1894                        "resume buffer backend error; falling back to snapshot+tail"
1895                    );
1896                }
1897                // ResumeError is non_exhaustive; future variants fall through
1898                // to snapshot+tail like NotFound.
1899                Err(_) => {}
1900            }
1901        }
1902        self.subscribe_snapshot_then_tail(ctx, params, task_store)
1903            .await
1904    }
1905
1906    /// Snapshot-then-tail fallback for `SubscribeToTask`. Reads current
1907    /// task state from the store, emits a synthetic replay event, then
1908    /// tails the broadcast until the first `final_event = true`.
1909    ///
1910    /// This is the pre-resumption path extracted verbatim from the
1911    /// original `dispatch_subscribe_to_task` body.
1912    #[instrument(
1913        skip_all,
1914        fields(klieo.stream_id = %params.id),
1915        level = "debug",
1916        err,
1917    )]
1918    async fn subscribe_snapshot_then_tail(
1919        &self,
1920        ctx: &RequestContext,
1921        params: SubscribeToTaskParams,
1922        task_store: &crate::task_store::A2aTaskStore,
1923    ) -> Result<TaskEventStream, A2aError> {
1924        match self.handler.subscribe_to_task(ctx, params.clone()).await {
1925            Ok(stream) => return Ok(stream),
1926            Err(A2aError::MethodNotFound(_)) => {}
1927            Err(other) => return Err(other),
1928        }
1929        let task = task_store
1930            .get(&params.id)
1931            .await?
1932            .ok_or_else(|| A2aError::TaskNotFound(params.id.clone()))?;
1933        let terminal = task.status.is_terminal();
1934        let event_id = task_store.next_event_id(&task.id);
1935        let initial = TaskEvent::new(
1936            task.id.clone(),
1937            task.status,
1938            task.history.last().cloned(),
1939            terminal,
1940        )
1941        .with_event_id(event_id);
1942        self.task_event_stream_with_initial(task.id, initial, terminal)
1943            .await
1944    }
1945
1946    /// Subscribe to the per-task pubsub subject and return a stream of
1947    /// `TaskEvent`s, closing after the first event with `final_event = true`.
1948    ///
1949    /// Each received [`klieo_core::Msg`] is ack-ed immediately on receipt;
1950    /// ephemeral SSE consumers prefer duplicate delivery over redelivery
1951    /// storms on disconnect. Bus errors are logged and skipped.
1952    #[instrument(
1953        skip_all,
1954        fields(klieo.stream_id = %task_id),
1955        level = "debug",
1956        err,
1957    )]
1958    async fn task_event_stream(&self, task_id: String) -> Result<TaskEventStream, A2aError> {
1959        klieo_core::validate_subject_token(&task_id)?;
1960        let subject = format!("klieo.a2a.task.{task_id}");
1961        let durable = DurableName::new(format!("klieo-eph-{}", uuid::Uuid::new_v4()));
1962        let msg_stream = self.pubsub.subscribe(&subject, durable).await?;
1963        let stream = futures::stream::unfold((msg_stream, false), move |(mut ms, done)| {
1964            let task_id = task_id.clone();
1965            async move {
1966                if done {
1967                    return None;
1968                }
1969                loop {
1970                    match ms.next().await {
1971                        None => return None,
1972                        Some(Err(e)) => {
1973                            warn!(
1974                                target: "a2a",
1975                                task_id = %task_id,
1976                                error = %e,
1977                                "task event stream bus error; skipping",
1978                            );
1979                            continue;
1980                        }
1981                        Some(Ok(msg)) => {
1982                            // Ack immediately — ephemeral SSE consumer.
1983                            if let Err(ack_err) = msg.ack.ack().await {
1984                                tracing::warn!(target: "a2a", error = %ack_err, "ack failed; message may redeliver");
1985                            }
1986                            // Extract W3C tracecontext from bus headers and
1987                            // parent the decode span under the publisher's
1988                            // span so cross-replica traces stitch into one
1989                            // tree (cluster 0.23, ADR-023).
1990                            let parent_cx = klieo_core::extract_traceparent(&msg.headers);
1991                            let decode_span = tracing::info_span!(
1992                                "task_event_decode",
1993                                messaging.system = "klieo-bus",
1994                                messaging.destination = %format!("klieo.a2a.task.{task_id}"),
1995                                messaging.operation = "receive",
1996                                klieo.stream_id = %task_id,
1997                            );
1998                            decode_span.set_parent(parent_cx);
1999                            let _enter = decode_span.enter();
2000                            match serde_json::from_slice::<TaskEvent>(&msg.payload) {
2001                                Err(e) => {
2002                                    warn!(
2003                                        target: "a2a",
2004                                        task_id = %task_id,
2005                                        error = %e,
2006                                        "task event decode error; skipping",
2007                                    );
2008                                    continue;
2009                                }
2010                                Ok(ev) => {
2011                                    let terminal = ev.final_event;
2012                                    return Some((ev, (ms, terminal)));
2013                                }
2014                            }
2015                        }
2016                    }
2017                }
2018            }
2019        });
2020        Ok(Box::pin(stream))
2021    }
2022
2023    async fn task_event_stream_with_initial(
2024        &self,
2025        task_id: String,
2026        initial: TaskEvent,
2027        initial_terminal: bool,
2028    ) -> Result<TaskEventStream, A2aError> {
2029        use futures::StreamExt as FutExt;
2030
2031        let initial_stream = futures::stream::once(futures::future::ready(initial));
2032        if initial_terminal {
2033            // Task already in a terminal state — emit the replay
2034            // event and close; no pubsub subscription needed.
2035            return Ok(Box::pin(initial_stream));
2036        }
2037        let tail = self.task_event_stream(task_id).await?;
2038        Ok(Box::pin(FutExt::chain(initial_stream, tail)))
2039    }
2040}
2041
2042/// JSON-RPC dispatcher that listens on a NATS subject.
2043pub struct A2aServer {
2044    app_prefix: String,
2045    agent_id: String,
2046    dispatcher: Arc<A2aDispatcher>,
2047    pubsub: Arc<dyn Pubsub>,
2048}
2049
2050impl A2aServer {
2051    /// Build a new server.
2052    ///
2053    /// **Authentication is mandatory** since 0.2.0. Wire a real
2054    /// [`Authenticator`] (e.g.
2055    /// [`BearerTokenAuthenticator`](crate::auth::BearerTokenAuthenticator))
2056    /// for production deployments. For tests and demos that intentionally
2057    /// run unauthenticated, pass
2058    /// [`AllowAnonymous`](crate::auth::AllowAnonymous) explicitly — that
2059    /// makes the choice visible at the wiring site.
2060    pub fn new(
2061        app_prefix: String,
2062        agent_id: String,
2063        handler: Arc<dyn A2aHandler>,
2064        authenticator: Arc<dyn Authenticator>,
2065        pubsub: Arc<dyn Pubsub>,
2066    ) -> Self {
2067        let dispatcher = Arc::new(A2aDispatcher::new(handler, authenticator, pubsub.clone()));
2068        Self {
2069            app_prefix,
2070            agent_id,
2071            dispatcher,
2072            pubsub,
2073        }
2074    }
2075
2076    /// Borrow the inner dispatcher (e.g. to share with an HTTP bridge
2077    /// mounted in the same process).
2078    pub fn dispatcher(&self) -> &Arc<A2aDispatcher> {
2079        &self.dispatcher
2080    }
2081
2082    /// Subject the server listens on.
2083    pub fn subject(&self) -> String {
2084        format!("{}.a2a.{}.rpc", self.app_prefix, self.agent_id)
2085    }
2086
2087    /// Run the dispatch loop until the underlying subscription terminates.
2088    pub async fn run(self) -> Result<(), A2aError> {
2089        let subject = self.subject();
2090        let durable = DurableName::new(format!("a2a-server-{}", self.agent_id));
2091        let mut stream = self.pubsub.subscribe(&subject, durable).await?;
2092        while let Some(item) = stream.next().await {
2093            match item {
2094                Ok(msg) => {
2095                    self.handle_one(msg).await;
2096                }
2097                Err(e) => {
2098                    error!(error = %e, "a2a subscribe stream error; exiting loop");
2099                    return Err(A2aError::Bus(e));
2100                }
2101            }
2102        }
2103        Ok(())
2104    }
2105
2106    async fn handle_one(&self, msg: Msg) {
2107        let reply_to = match msg.headers.get("Reply-To") {
2108            Some(s) => s.clone(),
2109            None => {
2110                warn!("a2a request missing Reply-To header; dropping");
2111                if let Err(ack_err) = msg.ack.ack().await {
2112                    tracing::warn!(target: "a2a", error = %ack_err, "ack failed; message may redeliver");
2113                }
2114                return;
2115            }
2116        };
2117        let headers = A2aHeaders::decode_from(&msg.headers);
2118        let response = self.dispatcher.handle_request(headers, &msg.payload).await;
2119        let bytes = match serde_json::to_vec(&response) {
2120            Ok(b) => Bytes::from(b),
2121            Err(e) => {
2122                error!(error = %e, "encode response");
2123                if let Err(ack_err) = msg.ack.ack().await {
2124                    tracing::warn!(target: "a2a", error = %ack_err, "ack failed; message may redeliver");
2125                }
2126                return;
2127            }
2128        };
2129        if let Err(e) = self.pubsub.publish(&reply_to, bytes, Headers::new()).await {
2130            error!(error = %e, "publish reply");
2131        }
2132        if let Err(ack_err) = msg.ack.ack().await {
2133            tracing::warn!(target: "a2a", error = %ack_err, "ack failed; message may redeliver");
2134        }
2135    }
2136}
2137
2138/// Probe a [`klieo_core::resume::ResumeBuffer`] for the highest
2139/// retained event id. Returns `None` when the buffer has no
2140/// retained events for `stream_id` (`NotFound`) OR when the
2141/// backend errors (best-effort: log warn + skip orphan write).
2142async fn max_event_id(
2143    resume_buffer: &dyn klieo_core::resume::ResumeBuffer,
2144    stream_id: &str,
2145) -> Option<u64> {
2146    let mut replay = match resume_buffer.replay(stream_id, 0).await {
2147        Ok(stream) => stream,
2148        Err(klieo_core::resume::ResumeError::NotFound(_)) => return None,
2149        Err(e) => {
2150            warn!(
2151                target: "a2a.leader",
2152                stream_id,
2153                error = %e,
2154                "max_event_id replay failed; skipping orphan terminal write",
2155            );
2156            return None;
2157        }
2158    };
2159    let mut highest: Option<u64> = None;
2160    while let Some((id, _)) = tokio_stream::StreamExt::next(&mut replay).await {
2161        highest = Some(match highest {
2162            Some(current) => current.max(id),
2163            None => id,
2164        });
2165    }
2166    highest
2167}
2168
2169/// Unwrap a `lookup_entry_with_revision` result, logging KV-layer
2170/// errors at warn before falling back to terminate. Returns
2171/// `Some((entry, rev))` only on `Ok(Some(_))`.
2172fn lookup_ok_or_log(
2173    key: &str,
2174    lookup: Result<Option<(klieo_core::LeaderEntry, klieo_core::Revision)>, klieo_core::BusError>,
2175) -> Option<(klieo_core::LeaderEntry, klieo_core::Revision)> {
2176    match lookup {
2177        Ok(Some(pair)) => Some(pair),
2178        Ok(None) => None,
2179        Err(e) => {
2180            warn!(
2181                target: "a2a.failover",
2182                key,
2183                error = %e,
2184                "leader entry lookup_with_revision failed; falling back to terminate",
2185            );
2186            None
2187        }
2188    }
2189}
2190
2191/// Build the cluster-0.24 `failover-reinvoke` SSE-marker frame bytes
2192/// recorded in the resume buffer at `max + 1` when a follower
2193/// re-invokes an idempotent handler from the cached payload. The
2194/// re-invoke's own events append at `max + 2` onwards via the
2195/// existing task-store event sink, so resume clients see the marker
2196/// between the original leader's last event and the re-invoke's
2197/// fresh sequence.
2198fn failover_reinvoke_sse_frame_bytes(
2199    task_id: &str,
2200    event_id: u64,
2201    attempt: u32,
2202    new_replica_id: &str,
2203) -> bytes::Bytes {
2204    let payload = serde_json::json!({
2205        "jsonrpc": "2.0",
2206        "id": serde_json::Value::Null,
2207        "event": "failover-reinvoke",
2208        "data": {
2209            "stream_id": format!("{A2A_LEADER_KEY_PREFIX}{task_id}"),
2210            "attempt": attempt,
2211            "by_replica": new_replica_id,
2212        },
2213        "event_id": event_id,
2214    });
2215    bytes::Bytes::from(serde_json::to_vec(&payload).unwrap_or_default())
2216}
2217
2218/// Build the terminal SSE-frame payload bytes that get recorded in
2219/// the resume buffer at `max_id + 1` when an orphan is detected.
2220/// The frame is the raw JSON-RPC error envelope that resume clients
2221/// see verbatim when replaying past the orphan boundary.
2222fn leader_died_sse_frame_bytes(task_id: &str, event_id: u64) -> bytes::Bytes {
2223    let payload = serde_json::json!({
2224        "jsonrpc": "2.0",
2225        "id": serde_json::Value::Null,
2226        "error": {
2227            "code": codes::LEADER_DIED,
2228            "message": "stream leader died",
2229            "data": { "stream_id": format!("{A2A_LEADER_KEY_PREFIX}{task_id}") }
2230        },
2231        "event_id": event_id,
2232    });
2233    bytes::Bytes::from(serde_json::to_vec(&payload).unwrap_or_default())
2234}
2235
2236/// Log an `A2aError` at the server-side seam BEFORE its wire envelope
2237/// is sent. The wire seam sanitises Display for internal classes
2238/// (`Bus`, `Server`, `Misconfigured`, `Internal`) — without this log
2239/// the source chain would never reach traces.
2240///
2241/// Logs at `warn` for client-class variants (`MethodNotFound`,
2242/// `InvalidParams`, `Unauthorized`, `TaskNotFound`,
2243/// `ResumeBufferExpired`, `Json`) and at `error` for internal-class
2244/// variants so operators can filter on the elevated level.
2245pub(crate) fn log_internal_before_wire_seam(err: &A2aError, method: &str) {
2246    let internal = matches!(
2247        err,
2248        A2aError::Bus(_)
2249            | A2aError::Server(_)
2250            | A2aError::Misconfigured(_)
2251            | A2aError::Internal { .. }
2252    );
2253    if internal {
2254        error!(
2255            target: "a2a.wire",
2256            method = %method,
2257            error = %err,
2258            source = ?std::error::Error::source(err),
2259            "internal error mapped to JSON-RPC wire envelope",
2260        );
2261    } else {
2262        warn!(
2263            target: "a2a.wire",
2264            method = %method,
2265            error = %err,
2266            "client-class error mapped to JSON-RPC wire envelope",
2267        );
2268    }
2269}
2270
2271/// Helper: deserialise params from raw JSON, run the handler closure,
2272/// then serialise the typed result back to a JSON [`Value`].
2273async fn run_method<P, T, Fut>(raw: Value, run: impl FnOnce(P) -> Fut) -> Result<Value, A2aError>
2274where
2275    P: DeserializeOwned,
2276    T: Serialize,
2277    Fut: std::future::Future<Output = Result<T, A2aError>>,
2278{
2279    let params: P =
2280        serde_json::from_value(raw).map_err(|e| A2aError::InvalidParams(e.to_string()))?;
2281    let typed = run(params).await?;
2282    serde_json::to_value(typed).map_err(A2aError::from)
2283}
2284
2285#[cfg(test)]
2286mod tests {
2287    use super::*;
2288    use crate::envelope::A2aHeaders;
2289    use crate::handler::EchoHandler;
2290    use crate::task_store::A2aTaskStore;
2291    use crate::types::{Task, TaskStatus};
2292    use klieo_auth_common::{AllowAnonymous, Identity};
2293    use klieo_bus_memory::MemoryBus;
2294
2295    fn anon_ctx() -> RequestContext {
2296        RequestContext::new(
2297            A2aHeaders::decode_from(&klieo_core::Headers::new()),
2298            Some(Identity::anonymous()),
2299        )
2300    }
2301
2302    fn make_store_with_dispatcher(dispatcher: &A2aDispatcher) -> A2aTaskStore {
2303        A2aTaskStore::new(
2304            Arc::new(MemoryBus::new()).kv.clone(),
2305            crate::task_store::DEFAULT_BUCKET.into(),
2306        )
2307        .with_event_sink(dispatcher.event_sink())
2308    }
2309
2310    fn make_task(id: &str, status: TaskStatus) -> Task {
2311        Task {
2312            id: id.into(),
2313            contextId: "ctx-1".into(),
2314            status,
2315            artifacts: vec![],
2316            history: vec![],
2317            metadata: None,
2318        }
2319    }
2320
2321    fn noop_resume_buffer() -> Arc<dyn klieo_core::resume::ResumeBuffer> {
2322        Arc::new(klieo_core::resume::NoopResumeBuffer)
2323    }
2324
2325    #[tokio::test]
2326    async fn local_wires_anonymous_auth_and_in_process_pubsub_and_dispatches() {
2327        let dispatcher = A2aDispatcher::local(Arc::new(EchoHandler::default()));
2328        let ctx = anon_ctx();
2329        let payload = br#"{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"messageId":"m1","role":"user","parts":[{"type":"text","content":"hi"}],"extensions":[],"referenceTaskIds":[]}}}"#;
2330        let resp = dispatcher.dispatch(&ctx, payload).await;
2331        assert_eq!(resp.id, serde_json::json!(1));
2332        assert!(resp.result.is_some());
2333        assert!(resp.error.is_none());
2334    }
2335
2336    #[tokio::test]
2337    async fn dispatcher_routes_send_message_to_handler() {
2338        let dispatcher = A2aDispatcher::with_in_process_pubsub(
2339            Arc::new(EchoHandler::default()),
2340            Arc::new(AllowAnonymous),
2341        );
2342        let ctx = anon_ctx();
2343        let payload = br#"{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"messageId":"m1","role":"user","parts":[{"type":"text","content":"hi"}],"extensions":[],"referenceTaskIds":[]}}}"#;
2344        let resp = dispatcher.dispatch(&ctx, payload).await;
2345        assert_eq!(resp.id, serde_json::json!(1));
2346        assert!(resp.result.is_some());
2347        assert!(resp.error.is_none());
2348    }
2349
2350    fn named_ctx(principal: &str) -> RequestContext {
2351        RequestContext::new(
2352            A2aHeaders::decode_from(&klieo_core::Headers::new()),
2353            Some(Identity::new(principal)),
2354        )
2355    }
2356
2357    const SEND_HI: &[u8] = br#"{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"messageId":"m1","role":"user","parts":[{"type":"text","content":"hi"}],"extensions":[],"referenceTaskIds":[]}}}"#;
2358
2359    #[tokio::test]
2360    async fn non_streaming_send_message_claims_ownership_so_foreign_get_task_is_denied() {
2361        let kv = Arc::new(MemoryBus::new()).kv.clone();
2362        let dispatcher = A2aDispatcher::builder()
2363            .handler(Arc::new(EchoHandler::default()))
2364            .authenticator(Arc::new(AllowAnonymous))
2365            .with_in_process_pubsub()
2366            .with_tenant_binding(kv)
2367            .build()
2368            .unwrap();
2369
2370        // alice creates a task via the non-streaming SendMessage path.
2371        let created = dispatcher.dispatch(&named_ctx("alice"), SEND_HI).await;
2372        let task_id = created.result.expect("SendMessage returns a task")["id"]
2373            .as_str()
2374            .expect("task id is a string")
2375            .to_string();
2376        let get = format!(
2377            r#"{{"jsonrpc":"2.0","id":2,"method":"GetTask","params":{{"id":"{task_id}"}}}}"#
2378        );
2379
2380        // A foreign tenant must NOT be able to read alice's task (CWE-639 IDOR):
2381        // deny-as-TaskNotFound, surfaced as a JSON-RPC error.
2382        let foreign = dispatcher.dispatch(&named_ctx("bob"), get.as_bytes()).await;
2383        assert!(
2384            foreign.error.is_some(),
2385            "foreign GetTask must be denied, got result {:?}",
2386            foreign.result
2387        );
2388
2389        // The owner can still read her own task.
2390        let owner = dispatcher
2391            .dispatch(&named_ctx("alice"), get.as_bytes())
2392            .await;
2393        assert!(
2394            owner.error.is_none(),
2395            "owner GetTask must succeed, got error {:?}",
2396            owner.error
2397        );
2398        assert!(owner.result.is_some());
2399    }
2400
2401    #[tokio::test]
2402    async fn dispatcher_streaming_subscribe_to_task_replays_current_state() {
2403        let dispatcher = A2aDispatcher::with_in_process_pubsub(
2404            Arc::new(EchoHandler::default()),
2405            Arc::new(AllowAnonymous),
2406        );
2407        let store = make_store_with_dispatcher(&dispatcher);
2408
2409        let task = make_task("t-1", TaskStatus::Working);
2410        store.put(&task).await.unwrap();
2411
2412        let payload =
2413            br#"{"jsonrpc":"2.0","id":1,"method":"SubscribeToTask","params":{"id":"t-1"}}"#;
2414        let ctx = anon_ctx();
2415        let mut stream = dispatcher
2416            .dispatch_streaming(&ctx, payload, &store, None, noop_resume_buffer())
2417            .await
2418            .unwrap();
2419
2420        let first = stream.next().await.expect("stream produced no first event");
2421        assert_eq!(first.task_id, "t-1");
2422        assert!(matches!(first.status, TaskStatus::Working));
2423        assert!(!first.final_event, "Working is not terminal");
2424    }
2425
2426    #[tokio::test]
2427    async fn dispatcher_streaming_rejects_unknown_task() {
2428        let dispatcher = A2aDispatcher::with_in_process_pubsub(
2429            Arc::new(EchoHandler::default()),
2430            Arc::new(AllowAnonymous),
2431        );
2432        let store = make_store_with_dispatcher(&dispatcher);
2433
2434        let payload =
2435            br#"{"jsonrpc":"2.0","id":1,"method":"SubscribeToTask","params":{"id":"nope"}}"#;
2436        let ctx = anon_ctx();
2437        let result = dispatcher
2438            .dispatch_streaming(&ctx, payload, &store, None, noop_resume_buffer())
2439            .await;
2440        assert!(matches!(result, Err(A2aError::TaskNotFound(_))));
2441    }
2442
2443    struct NoAnonAuthn;
2444
2445    #[async_trait::async_trait]
2446    impl Authenticator for NoAnonAuthn {
2447        async fn authenticate(
2448            &self,
2449            _headers: &dyn klieo_auth_common::Headers,
2450            _payload: &[u8],
2451        ) -> Result<Identity, klieo_auth_common::AuthError> {
2452            Ok(Identity::new("svc"))
2453        }
2454    }
2455
2456    #[tokio::test]
2457    async fn dispatcher_streaming_rejects_anonymous_under_named_authenticator() {
2458        // Mirror of the non-streaming anon-deny: a None caller on the streaming
2459        // path must be rejected when the authenticator forbids anonymous.
2460        let dispatcher = A2aDispatcher::with_in_process_pubsub(
2461            Arc::new(EchoHandler::default()),
2462            Arc::new(NoAnonAuthn),
2463        );
2464        let store = make_store_with_dispatcher(&dispatcher);
2465        let payload = br#"{"jsonrpc":"2.0","id":1,"method":"SendStreamingMessage","params":{"message":{"messageId":"m1","role":"user","parts":[{"type":"text","content":"hi"}],"extensions":[],"referenceTaskIds":[]}}}"#;
2466        let ctx = RequestContext::new(A2aHeaders::decode_from(&klieo_core::Headers::new()), None);
2467        let result = dispatcher
2468            .dispatch_streaming(&ctx, payload, &store, None, noop_resume_buffer())
2469            .await;
2470        assert!(matches!(result, Err(A2aError::Unauthorized(_))));
2471    }
2472
2473    #[tokio::test]
2474    async fn dispatcher_streaming_subscribe_terminal_task_closes_stream() {
2475        let dispatcher = A2aDispatcher::with_in_process_pubsub(
2476            Arc::new(EchoHandler::default()),
2477            Arc::new(AllowAnonymous),
2478        );
2479        let store = make_store_with_dispatcher(&dispatcher);
2480
2481        let task = make_task("t-done", TaskStatus::Completed);
2482        store.put(&task).await.unwrap();
2483
2484        let payload =
2485            br#"{"jsonrpc":"2.0","id":1,"method":"SubscribeToTask","params":{"id":"t-done"}}"#;
2486        let ctx = anon_ctx();
2487        let mut stream = dispatcher
2488            .dispatch_streaming(&ctx, payload, &store, None, noop_resume_buffer())
2489            .await
2490            .unwrap();
2491
2492        let first = stream.next().await.expect("expected replay event");
2493        assert_eq!(first.task_id, "t-done");
2494        assert!(first.final_event, "Completed must set final_event=true");
2495        // Stream must close after the terminal replay event.
2496        assert!(stream.next().await.is_none(), "stream must be exhausted");
2497    }
2498
2499    #[tokio::test]
2500    async fn dispatcher_streaming_send_streaming_message_returns_stream() {
2501        let dispatcher = A2aDispatcher::with_in_process_pubsub(
2502            Arc::new(EchoHandler::default()),
2503            Arc::new(AllowAnonymous),
2504        );
2505        let store = make_store_with_dispatcher(&dispatcher);
2506
2507        let payload = br#"{"jsonrpc":"2.0","id":1,"method":"SendStreamingMessage","params":{"message":{"messageId":"m1","role":"user","parts":[{"type":"text","content":"hi"}],"extensions":[],"referenceTaskIds":[]}}}"#;
2508        let ctx = anon_ctx();
2509        let result = dispatcher
2510            .dispatch_streaming(&ctx, payload, &store, None, noop_resume_buffer())
2511            .await;
2512        assert!(
2513            result.is_ok(),
2514            "dispatch_streaming should return Ok for SendStreamingMessage"
2515        );
2516    }
2517
2518    #[tokio::test]
2519    async fn handle_streaming_threads_cancel_token_into_request_context() {
2520        use crate::auth::RequestContext;
2521        use crate::error::A2aError;
2522        use crate::handler::A2aHandler;
2523        use crate::server::TaskEventStream;
2524        use crate::types::SendMessageParams;
2525        use klieo_auth_common::{AllowAnonymous, Authenticator};
2526        use std::sync::{Arc, Mutex};
2527        use tokio_util::sync::CancellationToken;
2528
2529        struct CancelSpy {
2530            observed: Mutex<Option<CancellationToken>>,
2531        }
2532        #[async_trait::async_trait]
2533        impl A2aHandler for CancelSpy {
2534            async fn send_streaming_message(
2535                &self,
2536                ctx: &RequestContext,
2537                _: SendMessageParams,
2538            ) -> Result<TaskEventStream, A2aError> {
2539                *self.observed.lock().unwrap() = Some(ctx.cancel.clone());
2540                Ok(Box::pin(futures::stream::empty()))
2541            }
2542        }
2543
2544        let spy = Arc::new(CancelSpy {
2545            observed: Mutex::new(None),
2546        });
2547        let dispatcher = A2aDispatcher::with_in_process_pubsub(
2548            spy.clone() as Arc<dyn A2aHandler>,
2549            Arc::new(AllowAnonymous) as Arc<dyn Authenticator>,
2550        );
2551        let store = A2aTaskStore::new(
2552            Arc::new(klieo_bus_memory::MemoryBus::new()).kv.clone(),
2553            crate::task_store::DEFAULT_BUCKET.into(),
2554        )
2555        .with_event_sink(dispatcher.event_sink());
2556
2557        let token = CancellationToken::new();
2558        let body = serde_json::to_vec(&serde_json::json!({
2559            "jsonrpc": "2.0",
2560            "id": 1,
2561            "method": "SendStreamingMessage",
2562            "params": {
2563                "message": {
2564                    "messageId": "m-spy",
2565                    "role": "user",
2566                    "parts": [],
2567                    "extensions": [],
2568                    "referenceTaskIds": []
2569                }
2570            }
2571        }))
2572        .unwrap();
2573
2574        let _ = dispatcher
2575            .handle_streaming(
2576                A2aHeaders::decode_from(&klieo_core::Headers::new()),
2577                &body,
2578                &store,
2579                token.clone(),
2580                None,
2581                noop_resume_buffer(),
2582            )
2583            .await
2584            .unwrap();
2585
2586        token.cancel();
2587        let observed = spy.observed.lock().unwrap().clone().unwrap();
2588        assert!(
2589            observed.is_cancelled(),
2590            "cancel must propagate from handle_streaming arg"
2591        );
2592    }
2593
2594    #[tokio::test]
2595    async fn task_event_sink_publishes_to_per_task_subject() {
2596        let bus = klieo_bus_memory::MemoryBus::new();
2597        let pubsub: std::sync::Arc<dyn klieo_core::Pubsub> = bus.pubsub.clone();
2598        let sink = TaskEventSink::new(pubsub.clone());
2599
2600        let durable = klieo_core::DurableName::new("test-eph");
2601        let mut stream = pubsub
2602            .subscribe("klieo.a2a.task.t-1", durable)
2603            .await
2604            .unwrap();
2605
2606        let event = TaskEvent::new(
2607            "t-1".to_string(),
2608            crate::types::TaskStatus::Submitted,
2609            None,
2610            false,
2611        )
2612        .with_event_id(1);
2613
2614        sink.send(event.clone()).await.unwrap();
2615
2616        use tokio_stream::StreamExt as _;
2617        let msg = tokio::time::timeout(std::time::Duration::from_millis(500), stream.next())
2618            .await
2619            .expect("timeout")
2620            .expect("stream ended")
2621            .expect("subscribe err");
2622        let decoded: TaskEvent = serde_json::from_slice(&msg.payload).unwrap();
2623        assert_eq!(decoded.task_id, "t-1");
2624        assert_eq!(decoded.event_id, 1);
2625        msg.ack.ack().await.unwrap();
2626    }
2627}
2628
2629#[cfg(test)]
2630mod profile_tests {
2631    use super::*;
2632    use crate::handler::EchoHandler;
2633    use klieo_auth_common::{AllowAnonymous, AuthError, Headers, Identity};
2634    use klieo_core::DeploymentProfile;
2635
2636    struct NamedAuthn;
2637
2638    #[async_trait::async_trait]
2639    impl Authenticator for NamedAuthn {
2640        async fn authenticate(
2641            &self,
2642            _headers: &dyn Headers,
2643            _payload: &[u8],
2644        ) -> Result<Identity, AuthError> {
2645            Ok(Identity::new("alice"))
2646        }
2647    }
2648
2649    fn builder_with(auth: Arc<dyn Authenticator>) -> A2aDispatcherBuilder {
2650        let bus = klieo_bus_memory::MemoryBus::new();
2651        A2aDispatcher::builder()
2652            .handler(Arc::new(EchoHandler::default()))
2653            .authenticator(auth)
2654            .pubsub(bus.pubsub.clone())
2655    }
2656
2657    #[test]
2658    fn regulated_without_tenant_kv_fails_closed() {
2659        let err = builder_with(Arc::new(NamedAuthn))
2660            .profile(DeploymentProfile::RegulatedMultiTenant)
2661            .build()
2662            .err()
2663            .expect("expected RegulatedProfile error");
2664        assert!(matches!(
2665            err,
2666            A2aBuilderError::RegulatedProfile(klieo_core::ProfileViolation::MissingTenantKv)
2667        ));
2668    }
2669
2670    #[test]
2671    fn regulated_with_anonymous_auth_fails_closed() {
2672        let bus = klieo_bus_memory::MemoryBus::new();
2673        let err = builder_with(Arc::new(AllowAnonymous))
2674            .with_tenant_binding(bus.kv.clone())
2675            .profile(DeploymentProfile::RegulatedMultiTenant)
2676            .build()
2677            .err()
2678            .expect("expected RegulatedProfile error");
2679        assert!(matches!(
2680            err,
2681            A2aBuilderError::RegulatedProfile(klieo_core::ProfileViolation::AnonymousAuth)
2682        ));
2683    }
2684
2685    #[test]
2686    fn regulated_forces_strict_over_lenient_binding() {
2687        let bus = klieo_bus_memory::MemoryBus::new();
2688        let dispatcher = builder_with(Arc::new(NamedAuthn))
2689            .with_tenant_binding(bus.kv.clone())
2690            .profile(DeploymentProfile::RegulatedMultiTenant)
2691            .build()
2692            .expect("regulated build with named auth + kv must succeed");
2693        assert_eq!(
2694            dispatcher
2695                .ownership_registry
2696                .as_ref()
2697                .map(|r| r.is_strict()),
2698            Some(true),
2699            "regulated profile must force a strict registry even over lenient binding"
2700        );
2701    }
2702
2703    #[test]
2704    fn unprofiled_keeps_lenient_binding() {
2705        let bus = klieo_bus_memory::MemoryBus::new();
2706        let dispatcher = builder_with(Arc::new(NamedAuthn))
2707            .with_tenant_binding(bus.kv.clone())
2708            .build()
2709            .expect("unprofiled build ok");
2710        assert_eq!(
2711            dispatcher
2712                .ownership_registry
2713                .as_ref()
2714                .map(|r| r.is_strict()),
2715            Some(false)
2716        );
2717    }
2718
2719    /// Dispatcher with lenient tenant binding wired. `AllowAnonymous`
2720    /// authorizes every method, so the only gate exercised is the
2721    /// per-resource ownership check keyed off the caller in the context.
2722    fn tenant_bound_dispatcher() -> A2aDispatcher {
2723        let bus = klieo_bus_memory::MemoryBus::new();
2724        A2aDispatcher::builder()
2725            .handler(Arc::new(EchoHandler::default()))
2726            .authenticator(Arc::new(AllowAnonymous))
2727            .pubsub(bus.pubsub.clone())
2728            .with_tenant_binding(bus.kv.clone())
2729            .build()
2730            .expect("tenant-bound dispatcher builds")
2731    }
2732
2733    fn named_ctx(principal: &str) -> RequestContext {
2734        RequestContext::new(
2735            A2aHeaders::decode_from(&klieo_core::Headers::new()),
2736            Some(Identity::new(principal)),
2737        )
2738    }
2739
2740    const SEND_MESSAGE_PAYLOAD: &[u8] = br#"{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"messageId":"m1","role":"user","parts":[{"type":"text","content":"hi"}],"extensions":[],"referenceTaskIds":[]}}}"#;
2741
2742    #[tokio::test]
2743    async fn anonymous_caller_rejected_when_authenticator_requires_identity() {
2744        let dispatcher = builder_with(Arc::new(NamedAuthn)).build().expect("build");
2745        let ctx = RequestContext::new(A2aHeaders::decode_from(&klieo_core::Headers::new()), None);
2746        let resp = dispatcher.dispatch(&ctx, SEND_MESSAGE_PAYLOAD).await;
2747        let err = resp.error.expect("anonymous caller must be rejected");
2748        assert_eq!(err.code, codes::UNAUTHENTICATED);
2749        assert!(
2750            resp.result.is_none(),
2751            "rejected request must carry no result"
2752        );
2753    }
2754
2755    /// Typed `SendMessageParams` for seeding a task on a handler directly,
2756    /// bypassing the (tenant-gated) dispatch path.
2757    fn hi_params() -> crate::types::SendMessageParams {
2758        serde_json::from_value(serde_json::json!({
2759            "message": {
2760                "messageId": "m1",
2761                "role": "user",
2762                "parts": [{"type": "text", "content": "hi"}],
2763                "extensions": [],
2764                "referenceTaskIds": []
2765            }
2766        }))
2767        .expect("valid SendMessageParams")
2768    }
2769
2770    async fn create_task(dispatcher: &A2aDispatcher, ctx: &RequestContext) -> String {
2771        let resp = dispatcher.dispatch(ctx, SEND_MESSAGE_PAYLOAD).await;
2772        resp.result
2773            .expect("SendMessage result")
2774            .get("id")
2775            .and_then(|v| v.as_str())
2776            .expect("created task carries an id")
2777            .to_string()
2778    }
2779
2780    #[tokio::test]
2781    async fn anonymous_send_message_records_no_ownership() {
2782        // The non-streaming claim skips anonymous callers (no principal to
2783        // bind, ADR-022). An anonymous SendMessage must leave no ownership
2784        // entry — not write one under an "anonymous" sentinel.
2785        let dispatcher = tenant_bound_dispatcher();
2786        let anon = RequestContext::new(
2787            A2aHeaders::decode_from(&klieo_core::Headers::new()),
2788            Some(Identity::anonymous()),
2789        );
2790        let created = dispatcher.dispatch(&anon, SEND_MESSAGE_PAYLOAD).await;
2791        let task_id = created.result.expect("SendMessage returns a task")["id"]
2792            .as_str()
2793            .expect("task id is a string")
2794            .to_string();
2795        let owner = dispatcher
2796            .ownership_registry()
2797            .expect("registry wired")
2798            .lookup(&format!("{A2A_OWNERSHIP_KEY_PREFIX}{task_id}"))
2799            .await
2800            .expect("lookup ok");
2801        assert!(
2802            owner.is_none(),
2803            "anonymous SendMessage must not write an ownership entry, got {owner:?}"
2804        );
2805    }
2806
2807    /// Claim ownership and RETURN the handle — the caller MUST keep it
2808    /// alive, since dropping it deletes the KV entry and reopens the gate.
2809    #[must_use]
2810    async fn claim_for(
2811        dispatcher: &A2aDispatcher,
2812        task_id: &str,
2813        principal: &str,
2814    ) -> klieo_core::OwnershipHandle {
2815        dispatcher
2816            .ownership_registry()
2817            .expect("ownership registry wired")
2818            .claim(
2819                format!("{A2A_OWNERSHIP_KEY_PREFIX}{task_id}"),
2820                principal.into(),
2821            )
2822            .await
2823            .expect("claim ownership")
2824    }
2825
2826    fn by_id_request(method: &str, task_id: &str) -> Vec<u8> {
2827        serde_json::to_vec(&serde_json::json!({
2828            "jsonrpc": "2.0",
2829            "id": 2,
2830            "method": method,
2831            "params": { "id": task_id },
2832        }))
2833        .expect("encode by-id request")
2834    }
2835
2836    #[tokio::test]
2837    async fn get_task_owner_reads_but_foreign_principal_denied_as_not_found() {
2838        let dispatcher = tenant_bound_dispatcher();
2839        let alice = named_ctx("alice");
2840        let task_id = create_task(&dispatcher, &alice).await;
2841        let _ownership = claim_for(&dispatcher, &task_id, "alice").await;
2842
2843        let request = by_id_request("GetTask", &task_id);
2844
2845        let owner = dispatcher.dispatch(&alice, &request).await;
2846        assert!(owner.error.is_none(), "owner must read own task: {owner:?}");
2847        assert_eq!(
2848            owner
2849                .result
2850                .expect("owner result")
2851                .get("id")
2852                .and_then(|v| v.as_str()),
2853            Some(task_id.as_str()),
2854        );
2855
2856        // Foreigner is denied — TaskNotFound (-32000), no body, never -32001.
2857        let bob = named_ctx("bob");
2858        let foreign = dispatcher.dispatch(&bob, &request).await;
2859        let err = foreign.error.expect("foreign principal must be denied");
2860        assert_eq!(err.code, codes::SERVER_ERROR);
2861        assert_ne!(
2862            err.code,
2863            codes::UNAUTHENTICATED,
2864            "deny must not surface -32001 (would leak existence info)",
2865        );
2866        assert!(
2867            err.message.contains("task not found"),
2868            "deny-as-not-found expected, got: {}",
2869            err.message,
2870        );
2871        assert!(foreign.result.is_none(), "must not leak the task body");
2872    }
2873
2874    #[tokio::test]
2875    async fn cancel_task_foreign_principal_denied_before_mutation() {
2876        let dispatcher = tenant_bound_dispatcher();
2877        let alice = named_ctx("alice");
2878        let task_id = create_task(&dispatcher, &alice).await;
2879        let _ownership = claim_for(&dispatcher, &task_id, "alice").await;
2880
2881        // Bob's cancel must be refused before the status flips.
2882        let bob = named_ctx("bob");
2883        let cancel = dispatcher
2884            .dispatch(&bob, &by_id_request("CancelTask", &task_id))
2885            .await;
2886        let err = cancel.error.expect("foreign cancel must be denied");
2887        assert_eq!(err.code, codes::SERVER_ERROR);
2888        assert!(err.message.contains("task not found"));
2889
2890        // Alice still sees the task in its pre-cancel state — proving the
2891        // gate ran before the handler's mutation, not after.
2892        let owner_view = dispatcher
2893            .dispatch(&alice, &by_id_request("GetTask", &task_id))
2894            .await;
2895        let status = owner_view
2896            .result
2897            .expect("owner result")
2898            .get("status")
2899            .and_then(|v| v.as_str())
2900            .map(str::to_string);
2901        assert_ne!(
2902            status.as_deref(),
2903            Some("canceled"),
2904            "foreign cancel must not mutate the task",
2905        );
2906    }
2907
2908    /// Every KV read fails, to drive the strict `Unavailable` verdict through
2909    /// `retain_owned_tasks`.
2910    struct UnavailableKv;
2911
2912    #[async_trait::async_trait]
2913    impl klieo_core::KvStore for UnavailableKv {
2914        async fn get(
2915            &self,
2916            _: &str,
2917            _: &str,
2918        ) -> Result<Option<klieo_core::KvEntry>, klieo_core::BusError> {
2919            Err(klieo_core::BusError::Connection("kv down".into()))
2920        }
2921        async fn put(
2922            &self,
2923            _: &str,
2924            _: &str,
2925            _: Bytes,
2926        ) -> Result<klieo_core::Revision, klieo_core::BusError> {
2927            Err(klieo_core::BusError::Connection("kv down".into()))
2928        }
2929        async fn cas(
2930            &self,
2931            _: &str,
2932            _: &str,
2933            _: Bytes,
2934            _: Option<klieo_core::Revision>,
2935        ) -> Result<klieo_core::Revision, klieo_core::BusError> {
2936            Err(klieo_core::BusError::Connection("kv down".into()))
2937        }
2938        async fn delete(&self, _: &str, _: &str) -> Result<(), klieo_core::BusError> {
2939            Err(klieo_core::BusError::Connection("kv down".into()))
2940        }
2941        async fn lease(
2942            &self,
2943            _: &str,
2944            _: &str,
2945            _: std::time::Duration,
2946        ) -> Result<klieo_core::Lease, klieo_core::BusError> {
2947            Err(klieo_core::BusError::Connection("kv down".into()))
2948        }
2949        async fn keys(&self, _: &str) -> Result<Vec<String>, klieo_core::BusError> {
2950            Err(klieo_core::BusError::Connection("kv down".into()))
2951        }
2952    }
2953
2954    #[tokio::test]
2955    async fn send_message_fails_closed_when_strict_ownership_store_unavailable() {
2956        // Under strict tenant binding a store-down on the ownership write must
2957        // deny the create rather than leave the task unprotected (an unrecorded
2958        // task is Allowed for every tenant by `enforce_owner`).
2959        let bus = klieo_bus_memory::MemoryBus::new();
2960        let dispatcher = A2aDispatcher::builder()
2961            .handler(Arc::new(EchoHandler::default()))
2962            .authenticator(Arc::new(AllowAnonymous))
2963            .pubsub(bus.pubsub.clone())
2964            .with_tenant_binding_strict(Arc::new(UnavailableKv))
2965            .build()
2966            .expect("strict tenant-bound dispatcher builds");
2967
2968        let resp = dispatcher
2969            .dispatch(&named_ctx("alice"), SEND_MESSAGE_PAYLOAD)
2970            .await;
2971        let err = resp
2972            .error
2973            .expect("strict store-down must fail the create closed");
2974        assert_eq!(err.code, codes::SERVER_ERROR);
2975        assert!(resp.result.is_none(), "denied create must carry no task");
2976    }
2977
2978    #[tokio::test]
2979    async fn list_tasks_fails_closed_when_ownership_store_unavailable() {
2980        // Seed a task directly on the handler: the strict store-down below would
2981        // also fail the SendMessage create closed, so the seed cannot go through
2982        // dispatch — this test isolates the ListTasks (`retain_owned_tasks`) gate.
2983        let handler = Arc::new(EchoHandler::default());
2984        handler
2985            .send_message(&named_ctx("alice"), hi_params())
2986            .await
2987            .expect("seed task on handler");
2988        let bus = klieo_bus_memory::MemoryBus::new();
2989        let dispatcher = A2aDispatcher::builder()
2990            .handler(handler)
2991            .authenticator(Arc::new(AllowAnonymous))
2992            .pubsub(bus.pubsub.clone())
2993            .with_tenant_binding_strict(Arc::new(UnavailableKv))
2994            .build()
2995            .expect("strict tenant-bound dispatcher builds");
2996
2997        let alice = named_ctx("alice");
2998        let list_request = serde_json::to_vec(&serde_json::json!({
2999            "jsonrpc": "2.0", "id": 4, "method": "ListTasks", "params": {},
3000        }))
3001        .expect("encode ListTasks");
3002        let resp = dispatcher.dispatch(&alice, &list_request).await;
3003        let err = resp
3004            .error
3005            .expect("a strict store-down must fail the list closed");
3006        assert_eq!(err.code, codes::SERVER_ERROR);
3007        assert!(
3008            err.message.contains("unavailable") || err.message.contains("list denied"),
3009            "fail-closed message expected, got: {}",
3010            err.message,
3011        );
3012        assert!(resp.result.is_none(), "must not return a partial list");
3013    }
3014
3015    #[tokio::test]
3016    async fn push_notification_config_arms_deny_foreign_principal() {
3017        let dispatcher = tenant_bound_dispatcher();
3018        let alice = named_ctx("alice");
3019        let task_id = create_task(&dispatcher, &alice).await;
3020        let _ownership = claim_for(&dispatcher, &task_id, "alice").await;
3021        let bob = named_ctx("bob");
3022
3023        // Each push-config method is keyed by `taskId`; a foreign principal
3024        // must be gated before the handler (deny-as-NotFound), while the owner
3025        // passes the gate and reaches the handler default (MethodNotFound on
3026        // EchoHandler). The differing codes prove the gate fires for bob only.
3027        let requests = [
3028            serde_json::json!({"jsonrpc":"2.0","id":5,"method":"CreateTaskPushNotificationConfig","params":{"taskId":task_id,"url":"https://example.test/hook"}}),
3029            serde_json::json!({"jsonrpc":"2.0","id":6,"method":"GetTaskPushNotificationConfig","params":{"taskId":task_id,"id":"c1"}}),
3030            serde_json::json!({"jsonrpc":"2.0","id":7,"method":"ListTaskPushNotificationConfigs","params":{"taskId":task_id}}),
3031            serde_json::json!({"jsonrpc":"2.0","id":8,"method":"DeleteTaskPushNotificationConfig","params":{"taskId":task_id,"id":"c1"}}),
3032        ];
3033
3034        for request in requests {
3035            let method = request["method"].as_str().expect("method").to_string();
3036            let body = serde_json::to_vec(&request).expect("encode push-config request");
3037
3038            let foreign = dispatcher.dispatch(&bob, &body).await;
3039            let foreign_err = foreign
3040                .error
3041                .unwrap_or_else(|| panic!("{method}: foreign principal must be denied"));
3042            assert_eq!(
3043                foreign_err.code,
3044                codes::SERVER_ERROR,
3045                "{method}: foreign principal must be denied-as-not-found",
3046            );
3047            assert!(
3048                foreign_err.message.contains("task not found"),
3049                "{method}: expected deny-as-not-found, got {}",
3050                foreign_err.message,
3051            );
3052            assert!(foreign.result.is_none(), "{method}: must not leak a body");
3053
3054            let owner = dispatcher.dispatch(&alice, &body).await;
3055            let owner_err = owner
3056                .error
3057                .unwrap_or_else(|| panic!("{method}: EchoHandler declines push-config"));
3058            assert_eq!(
3059                owner_err.code,
3060                codes::METHOD_NOT_FOUND,
3061                "{method}: owner must pass the gate and reach the handler default",
3062            );
3063        }
3064    }
3065
3066    #[tokio::test]
3067    async fn list_tasks_drops_tasks_the_caller_does_not_own() {
3068        let dispatcher = tenant_bound_dispatcher();
3069        let alice = named_ctx("alice");
3070        let bob = named_ctx("bob");
3071
3072        // Two tasks in one handler store, one owned by each principal.
3073        let alice_task = create_task(&dispatcher, &alice).await;
3074        let bob_task = create_task(&dispatcher, &bob).await;
3075        let _alice_owns = claim_for(&dispatcher, &alice_task, "alice").await;
3076        let _bob_owns = claim_for(&dispatcher, &bob_task, "bob").await;
3077
3078        let list_request = serde_json::to_vec(&serde_json::json!({
3079            "jsonrpc": "2.0", "id": 3, "method": "ListTasks", "params": {},
3080        }))
3081        .expect("encode ListTasks");
3082
3083        let resp = dispatcher.dispatch(&bob, &list_request).await;
3084        let ids: Vec<String> = resp
3085            .result
3086            .expect("list result")
3087            .get("tasks")
3088            .and_then(|v| v.as_array())
3089            .expect("tasks array")
3090            .iter()
3091            .filter_map(|t| t.get("id").and_then(|v| v.as_str()).map(str::to_string))
3092            .collect();
3093
3094        assert!(ids.contains(&bob_task), "owner must see own task: {ids:?}");
3095        assert!(
3096            !ids.contains(&alice_task),
3097            "list must not leak a foreign-owned task: {ids:?}",
3098        );
3099    }
3100}