Skip to main content

agentplane/api/
a2a.rs

1//! Serving A2A: this plane, as an agent other agents can call.
2//!
3//! The client side ([`crate::peers::a2a`]) lets this plane *call* peers. This is
4//! the other half — a peer calling us — and it is a different problem, because
5//! everything arriving here came from somebody else.
6//!
7//! # Why this is not a route on the operator API
8//!
9//! Every route on [`crate::api::Api`] authenticates and then authorizes. An
10//! Agent Card is public by design: it is what a caller reads *before* it has
11//! credentials, and a card behind authentication cannot be discovered. Bolting
12//! an unauthenticated path onto a surface whose invariant is "every route
13//! authenticates" would delete that invariant for the one route nobody would
14//! think to check.
15//!
16//! So this is its own router with its own rule: **the card is public, every
17//! method call is authenticated and authorized**.
18//!
19//! # What arrives here is untrusted
20//!
21//! A message from a peer is data written by a party this plane does not control,
22//! so it is admitted as `Tainted` with the sending
23//! peer's identity as its provenance source — never as trusted input. A skill
24//! that wants to act on it has to say so at a gate, and a protected sink field
25//! can name the one counterparty it will accept an amount from.
26//!
27//! This is the same reason the operator API takes an event's `source` from the
28//! authenticated caller rather than the body: a party describing itself is not
29//! evidence about itself.
30//!
31//! # The capability is named, never inferred
32//!
33//! A2A messages do not carry a "call this skill" field — the protocol assumes an
34//! agent works out what is being asked. This plane will not: choosing which
35//! capability to run on the strength of an untrusted message is a dispatch
36//! decision made by inference, and the thing doing the inferring would be a
37//! model reading attacker-controlled text.
38//!
39//! The skill is taken from `message.metadata.skill`, matched against the card's
40//! advertised skill ids. When the agent advertises exactly one there is nothing
41//! to infer and it is used; when it advertises several and none was named, the
42//! call is refused rather than guessed.
43//!
44//! # Optional capabilities say what is wired
45//!
46//! Streaming and non-terminal task subscription are durable journal views.
47//! Push is advertised only after `with_push` supplies durable registration
48//! storage and a retrying transport worker; without that wiring every push
49//! method uses `PushNotificationNotSupportedError` and the card advertises
50//! false.
51//!
52//! # A task belongs to the peer that admitted it
53//!
54//! Every read, write, stream, push configuration and `contextId` join is scoped
55//! to the admitting peer; another peer's task answers `TASK_NOT_FOUND`, never a
56//! refusal that confirms it exists.
57
58use std::sync::Arc;
59
60use axum::extract::State;
61use axum::http::{HeaderMap, StatusCode};
62use axum::response::{IntoResponse, Response};
63use axum::routing::{get, post};
64use axum::{Json, Router};
65use serde::{Deserialize, Serialize};
66use serde_json::{Value, json};
67
68use crate::core::{PolicyDecision, PolicyRequest, RunId, Seq, SourceId, Tainted};
69use crate::journal::RecordKind;
70use crate::manifest::Manifest;
71use crate::peers::{AgentCard, ExtendedAgentCard, WELL_KNOWN_PATH};
72use crate::push::PushNamespace;
73use crate::runtime::Runtime;
74
75use super::{Authenticator, Caller};
76
77/// JSON-RPC method names, exactly as A2A 1.0 spells them.
78///
79/// 1.0 renamed these: `message/send` was the 0.3 spelling, and a server
80/// answering the old names would silently accept clients that have lost half the
81/// protocol. Constants rather than inline literals so the dispatch table and the
82/// refusal table cannot disagree.
83pub mod method {
84    pub const SEND_MESSAGE: &str = "SendMessage";
85    pub const GET_TASK: &str = "GetTask";
86    pub const CANCEL_TASK: &str = "CancelTask";
87    pub const GET_EXTENDED_CARD: &str = "GetExtendedAgentCard";
88
89    /// Optional operations implemented by this server.
90    pub const SEND_STREAMING: &str = "SendStreamingMessage";
91    pub const SUBSCRIBE: &str = "SubscribeToTask";
92    pub const LIST_TASKS: &str = "ListTasks";
93    pub const CREATE_PUSH: &str = "CreateTaskPushNotificationConfig";
94    pub const GET_PUSH: &str = "GetTaskPushNotificationConfig";
95    pub const LIST_PUSH: &str = "ListTaskPushNotificationConfigs";
96    pub const DELETE_PUSH: &str = "DeleteTaskPushNotificationConfig";
97}
98
99/// A2A-specific JSON-RPC error codes, from the spec's mapping table.
100pub mod code {
101    pub const PARSE_ERROR: i32 = -32700;
102    pub const INVALID_REQUEST: i32 = -32600;
103    pub const METHOD_NOT_FOUND: i32 = -32601;
104    pub const INVALID_PARAMS: i32 = -32602;
105    pub const INTERNAL_ERROR: i32 = -32603;
106
107    pub const TASK_NOT_FOUND: i32 = -32001;
108    pub const TASK_NOT_CANCELABLE: i32 = -32002;
109    pub const PUSH_NOT_SUPPORTED: i32 = -32003;
110    pub const UNSUPPORTED_OPERATION: i32 = -32004;
111    pub const CONTENT_TYPE_NOT_SUPPORTED: i32 = -32005;
112    pub const EXTENDED_CARD_NOT_CONFIGURED: i32 = -32007;
113    pub const VERSION_NOT_SUPPORTED: i32 = -32009;
114
115    /// Admission back-pressure: a quota ceiling refused the request before any
116    /// work was admitted.
117    ///
118    /// Not in A2A 1.0's error table — the spec defines only permanent
119    /// missing-capability codes and assigns back-pressure no code at all; its
120    /// own guidance stops at "return appropriate error responses when rate
121    /// limits are exceeded". Reaching into the table anyway would be worse
122    /// than inventing a code: `-32004 UnsupportedOperationError` for a full
123    /// quota teaches a compliant caller that the *operation does not exist
124    /// here* — the correct response to which is to abandon, never to retry —
125    /// when the truthful answer is "not right now". So the code is drawn from
126    /// JSON-RPC's implementation-defined server-error range.
127    ///
128    /// The **number is not the identity**. A2A 1.0 reserves `-32001..-32099`
129    /// for its own errors, the same band JSON-RPC gives implementations, so a
130    /// future spec revision could assign this numeral a meaning of its own.
131    /// What makes the refusal identifiable is the `google.rpc.ErrorInfo`
132    /// beside it — [`ERROR_DOMAIN`](crate::peers::ERROR_DOMAIN) +
133    /// [`QUOTA_EXHAUSTED_REASON`](crate::peers::QUOTA_EXHAUSTED_REASON), a
134    /// domain this project controls — and that pair is what this crate's own
135    /// client keys its refuse-and-come-back classification on. A bare
136    /// `-32029` from a foreign server proves nothing and is classified as an
137    /// unknown fault.
138    ///
139    /// What this code does NOT carry is the quota arithmetic: declines on this
140    /// surface are deliberately uniform, and counters or limits in the message
141    /// would hand an unauthenticated prober the tenant's ceilings. The message
142    /// beside this code is a fixed sentence with no numbers in it.
143    pub const QUOTA_EXHAUSTED: i32 = -32029;
144
145    /// This agent is halted by its operator.
146    ///
147    /// Its own code, beside [`QUOTA_EXHAUSTED`] and
148    /// under the same identification rule — the `(domain, reason)` pair, never
149    /// the numeral — because the two ask opposite things of a caller. A ceiling
150    /// says *come back*; a halt says *somebody is dealing with an incident*,
151    /// and a peer told to back off and retry keeps knocking on the one door
152    /// that means stop. The message is fixed and carries none of the
153    /// operator's reason: the counterparty gets the outcome, not the plane's
154    /// internals.
155    pub const HALTED: i32 = -32030;
156
157    /// This instance is shutting down and did not admit the request.
158    ///
159    /// The third admission refusal, under the same identification rule — the
160    /// `(domain, reason)` pair, never the numeral. It is its own code because
161    /// it is the only one of the three a caller clears by *moving*: a ceiling
162    /// and a halt are facts about the agent, and this is a fact about one
163    /// process serving it. A peer told to back off waits out a window it never
164    /// needed; a peer told to abandon gives up on an agent that is fine.
165    pub const DRAINING: i32 = -32031;
166}
167
168/// Actions this surface asks the policy engine about.
169pub mod action {
170    pub const MESSAGE_SEND: &str = "a2a:message.send";
171    pub const TASK_READ: &str = "a2a:task.read";
172    pub const TASK_CONTINUE: &str = "a2a:task.continue";
173    pub const TASK_CANCEL: &str = "a2a:task.cancel";
174    pub const CARD_EXTENDED: &str = "a2a:card.extended";
175    /// Registering, reading or removing a webhook for a task.
176    pub const TASK_PUSH: &str = "a2a:task.push";
177    /// Supplying an event of a kind, asked on the kind: a continuation is
178    /// delivered as the event its task awaits, so a peer may continue a task
179    /// only with input a rule lets it supply.
180    pub const EVENT_DELIVER: &str = "a2a:event.deliver";
181
182    /// Every action this surface can ask about, so a deployment can enumerate
183    /// what it must write rules for.
184    pub const ALL: &[&str] = &[
185        MESSAGE_SEND,
186        TASK_READ,
187        TASK_CONTINUE,
188        TASK_CANCEL,
189        CARD_EXTENDED,
190        TASK_PUSH,
191        EVENT_DELIVER,
192    ];
193}
194
195/// The policy context every A2A action is asked under: roles, the peer's own
196/// name, and tenant — no chain, no label, no depth. A task action adds the
197/// task's `owner` ([`A2aServer::context`]).
198fn peer_context(caller: &Caller) -> Value {
199    json!({
200        "roles": caller.roles,
201        "peer": caller.actor,
202        "tenant": caller.tenant.as_str(),
203    })
204}
205
206/// Whether `engine` can evaluate every request this surface puts to it.
207///
208/// Each [`action::ALL`] verb is probed in exactly the shapes it is asked in:
209/// with the task's `owner` where it acts on a task, and without it where it
210/// does not — sending, the extended card, the `tasks` listing, and an inline
211/// push registration for a task that does not exist yet. Probing a task action
212/// without its owner would refuse a rule set that reads `context.owner`, which
213/// every such request carries; probing the listing with one would miss a rule
214/// that breaks on the request that carries none. [`A2aServer::hosting`]
215/// refuses a runtime whose engine reports anything: a rule this surface
216/// cannot evaluate declines every peer, and the decline says nothing to the
217/// peer about why.
218#[must_use]
219pub fn policy_problems(engine: &dyn crate::core::PolicyEngine) -> Vec<String> {
220    let caller = Caller::new("preflight", vec!["preflight".to_owned()]);
221    let bare = peer_context(&caller);
222    let owned = A2aServer::context(&caller, Some(&caller.actor));
223    let shapes: &[(&str, &Value)] = &[
224        (action::MESSAGE_SEND, &bare),
225        (action::TASK_READ, &owned),
226        (action::TASK_READ, &bare),
227        (action::TASK_CONTINUE, &owned),
228        (action::TASK_CANCEL, &owned),
229        (action::CARD_EXTENDED, &bare),
230        (action::TASK_PUSH, &owned),
231        (action::TASK_PUSH, &bare),
232        (action::EVENT_DELIVER, &owned),
233    ];
234    let requests: Vec<PolicyRequest<'_>> = shapes
235        .iter()
236        .map(|(action, context)| PolicyRequest {
237            principal: &caller.actor,
238            principal_kind: crate::core::PrincipalKind::Subject,
239            action,
240            resource: "preflight.resource",
241            context,
242        })
243        .collect();
244    engine.preflight(&requests)
245}
246
247/// The `A2A-Version` service parameter.
248const VERSION_HEADER: &str = "a2a-version";
249
250/// The one sentence a full quota answers with, beside
251/// [`code::QUOTA_EXHAUSTED`].
252///
253/// A single constant rather than `why.to_string()` at each site, because the
254/// quota error's own rendering names the counter and its ceiling — numbers an
255/// external caller has no business learning from a decline. Deliberately
256/// digit-free; the test on this path asserts exactly that.
257const QUOTA_EXHAUSTED_MESSAGE: &str =
258    "this agent cannot take the request on right now; retry later";
259
260/// The one sentence a halt answers with, beside [`code::HALTED`]. No reason,
261/// no scope: an incident's description is for the operator's own worklist.
262const HALTED_MESSAGE: &str =
263    "this agent is halted by its operator; do not retry until it is lifted";
264
265/// The one sentence a draining instance answers with, beside [`code::DRAINING`].
266///
267/// No hostname, no instance id and no grace period: which process is going away
268/// is this deployment's topology, and a caller's correct behaviour does not
269/// depend on knowing it.
270const DRAINING_MESSAGE: &str =
271    "this instance is shutting down and did not take the request on; retry";
272
273/// Which of the two admission refusals a quota error is, on the wire.
274///
275/// One function for the three admission sites, because a halt that reached one
276/// of them as a ceiling would be the sibling-divergence shape: a peer told
277/// *retry* by `message/send` and *stop* by `message/stream`.
278fn quota_refusal(e: &crate::quota::QuotaError) -> RpcError {
279    match e {
280        crate::quota::QuotaError::Halted { .. } => RpcError::new(code::HALTED, HALTED_MESSAGE),
281        _ => RpcError::new(code::QUOTA_EXHAUSTED, QUOTA_EXHAUSTED_MESSAGE),
282    }
283}
284
285/// A message its agent cannot read a data subject from, on the wire: invalid
286/// params at every admission site. The error names the binding and why it
287/// did not resolve, never a subject.
288fn subject_unbound(e: &crate::core::RuntimeError) -> RpcError {
289    RpcError::new(code::INVALID_PARAMS, e.to_string())
290}
291
292/// A task's lifecycle state, as A2A names them.
293///
294/// `ProtoJSON` spelling — `TASK_STATE_WORKING`, not `working`. A client matching
295/// on the enum gets nothing from a friendlier spelling.
296///
297/// # Why this is the whole vocabulary, not the part this plane produces
298///
299/// It is **both** an output and an input: `ListTasks` takes a `status` filter,
300/// so a caller may name any state A2A defines and must get a valid, empty
301/// answer rather than `INVALID_PARAMS`. A subset would turn a legitimate query
302/// into a protocol error.
303///
304/// Two of these this plane never *produces*: `TASK_STATE_UNSPECIFIED`, the
305/// protocol's zero value, and `TASK_STATE_SUBMITTED`, which means accepted but
306/// not yet started — there is no such moment here, because a run starts inside
307/// the call that admits it.
308///
309/// `TASK_STATE_AUTH_REQUIRED` is produced for a run whose authority was
310/// withdrawn under it: interrupted, not terminal, so lifting the withdrawal
311/// continues the same task.
312///
313/// `TASK_STATE_REJECTED` **is** produced, and not as a refusal: a sweep, a
314/// break-glass crossing, a lifted control and an observed session take it,
315/// because none is a task any peer submitted. A caller polling one of those is
316/// asking about this plane's record of itself, and gets the same answer as for
317/// a run id that does not exist at all.
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
319pub enum TaskState {
320    #[serde(rename = "TASK_STATE_UNSPECIFIED")]
321    Unspecified,
322    #[serde(rename = "TASK_STATE_SUBMITTED")]
323    Submitted,
324    #[serde(rename = "TASK_STATE_WORKING")]
325    Working,
326    #[serde(rename = "TASK_STATE_COMPLETED")]
327    Completed,
328    #[serde(rename = "TASK_STATE_FAILED")]
329    Failed,
330    #[serde(rename = "TASK_STATE_CANCELED")]
331    Canceled,
332    #[serde(rename = "TASK_STATE_INPUT_REQUIRED")]
333    InputRequired,
334    #[serde(rename = "TASK_STATE_REJECTED")]
335    Rejected,
336    #[serde(rename = "TASK_STATE_AUTH_REQUIRED")]
337    AuthRequired,
338}
339
340/// The A2A state a live run's status surfaces as.
341///
342/// These are worth stating because the obvious mapping is wrong.
343///
344/// A **suspended** run is `INPUT_REQUIRED`, not `WORKING`: it is stopped and
345/// will not move until something external happens, and a caller polling a
346/// `WORKING` task waits forever for a task that is not running.
347///
348/// A **quarantined** run is `FAILED`, not `REJECTED`. `REJECTED` means the agent
349/// declined the work; quarantine means it accepted the work, started, and can no
350/// longer be trusted to describe what it did. Reporting that as a refusal tells
351/// the caller nothing happened, when something did.
352///
353/// A **withheld** run is `AUTH_REQUIRED`: its authority was withdrawn under
354/// it, its work stands, and it continues once the authorization is restored.
355/// Interrupted rather than terminal, so a stream stays open and a push
356/// registration stays live. Restoring the authorization is the deployment's
357/// act, not the peer's — the status message carries the outcome so nobody
358/// reads it as a request for credentials.
359///
360/// A **failed or exhausted** run is `FAILED` although the runtime can resume
361/// it: A2A has no interrupted state for "stopped until this deployment's
362/// operator acts", so the answer is the run's state at read time. The stream
363/// closes and the push registration is released as for any terminal task; an
364/// operator's resume is a fresh act, and `GetTask` then reports it working.
365///
366/// This and [`sealed_state`] are the **same question asked on two paths**: this
367/// one answers the caller who receives the immediate `SendMessage` response, and
368/// `sealed_state` answers everyone who reads the task back — `GetTask`,
369/// `SubscribeToTask`, and every streamed status update. They must agree, and
370/// `a_live_status_and_its_sealed_outcome_agree` holds them to it over every
371/// variant.
372///
373/// **This is the exhaustive one, and that is the point.** The two were unified
374/// once by making this function delegate to `sealed_state`, which reads as the
375/// tidier direction and is the wrong one: `sealed_state` matches *strings*
376/// behind a `_ => Failed`, so delegating to it deleted the only compile-time
377/// check on the mapping while leaving a comment claiming the compiler still
378/// enforced it. Adding a `RunStatus` variant for something that is **not** a
379/// failure — an authorization wait, a rejection — would then have compiled
380/// cleanly and reported `Failed` on both paths. That is not agreement; it is
381/// the same wrong answer twice, which is strictly harder to notice than two
382/// different ones.
383///
384/// So the enum match is the definition and the string match is checked against
385/// it. A new variant fails to compile *here*, which is where the decision
386/// belongs.
387fn state_of(status: &crate::runtime::RunStatus) -> TaskState {
388    use crate::runtime::RunStatus;
389    match status {
390        RunStatus::Succeeded => TaskState::Completed,
391        RunStatus::Suspended(_) => TaskState::InputRequired,
392        RunStatus::Cancelled { .. } => TaskState::Canceled,
393        RunStatus::Failed(_)
394        | RunStatus::Exhausted(_)
395        | RunStatus::Quarantined(_)
396        // Not `Canceled`: A2A's cancellation means a client asked and the work
397        // stopped, which a peer reads as "nothing of mine is standing". An
398        // abandoned run is the opposite claim — something may well be standing
399        // and nobody could establish what — so it takes the state a peer
400        // investigates rather than the one they dismiss.
401        | RunStatus::Abandoned { .. }
402        | RunStatus::Replanning(_) => TaskState::Failed,
403        RunStatus::Withheld { .. } => TaskState::AuthRequired,
404        // Neither is a task, and `REJECTED` is the state that says so: no peer
405        // submitted a sweep or a break-glass crossing, and a run id that
406        // resolves to one is a caller asking about this plane's record of
407        // itself. `FAILED` would claim the agent tried and could not; `CANCELED`
408        // would claim somebody's work was stopped. Both invent a task where
409        // there was none, and a peer reads either as being about *their*
410        // request. Nothing narrows further here on purpose — which of this
411        // plane's internal runs exists is not a peer's question, so the answer
412        // is the same one for a run that does not exist at all.
413        // An observed session joins them for the same reason and one more: it
414        // is not even this plane's work. No peer submitted it, nothing here
415        // executed it, and the only honest thing to tell a caller asking about
416        // somebody else's agent is the answer they get for a run that is none
417        // of their business.
418        RunStatus::Swept
419        | RunStatus::BrokeGlass { .. }
420        | RunStatus::HaltLifted { .. }
421        | RunStatus::HoldReleased { .. }
422        | RunStatus::Observed => TaskState::Rejected,
423    }
424}
425
426/// A2A's `TaskStatus`.
427#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
428pub struct TaskStatus {
429    pub state: TaskState,
430    #[serde(skip_serializing_if = "Option::is_none")]
431    pub message: Option<A2aMessage>,
432    #[serde(skip_serializing_if = "Option::is_none")]
433    pub timestamp: Option<String>,
434}
435
436/// A2A's `Task` — what this plane calls a run.
437#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
438#[serde(rename_all = "camelCase")]
439pub struct A2aTask {
440    pub id: String,
441    /// The case, when the run belongs to one.
442    ///
443    /// A2A's `contextId` is "the thing this conversation is part of", which is
444    /// exactly a case: several runs, one matter, shared state. Mapping it to
445    /// anything else would give a caller a correlation handle that does not
446    /// correlate.
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub context_id: Option<String>,
449    pub status: TaskStatus,
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub artifacts: Option<Vec<A2aArtifact>>,
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub history: Option<Vec<A2aMessage>>,
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub metadata: Option<Value>,
456}
457
458/// One output produced by an A2A task.
459#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
460#[serde(rename_all = "camelCase")]
461pub struct A2aArtifact {
462    pub artifact_id: String,
463    #[serde(skip_serializing_if = "Option::is_none")]
464    pub name: Option<String>,
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub description: Option<String>,
467    pub parts: Vec<Part>,
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub metadata: Option<Value>,
470    #[serde(default, skip_serializing_if = "Vec::is_empty")]
471    pub extensions: Vec<String>,
472}
473
474/// One piece of a message.
475#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
476#[serde(rename_all = "camelCase")]
477pub struct Part {
478    #[serde(skip_serializing_if = "Option::is_none")]
479    pub text: Option<String>,
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub data: Option<Value>,
482    #[serde(skip_serializing_if = "Option::is_none")]
483    pub raw: Option<String>,
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub url: Option<String>,
486    #[serde(skip_serializing_if = "Option::is_none")]
487    pub filename: Option<String>,
488    #[serde(skip_serializing_if = "Option::is_none")]
489    pub media_type: Option<String>,
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub metadata: Option<Value>,
492}
493
494impl Part {
495    /// A text part.
496    #[must_use]
497    pub fn text(text: impl Into<String>) -> Self {
498        Self {
499            text: Some(text.into()),
500            data: None,
501            raw: None,
502            url: None,
503            filename: None,
504            media_type: Some("text/plain".to_owned()),
505            metadata: None,
506        }
507    }
508
509    /// A structured-data part.
510    #[must_use]
511    pub fn data(data: Value) -> Self {
512        Self {
513            text: None,
514            data: Some(data),
515            raw: None,
516            url: None,
517            filename: None,
518            media_type: Some("application/json".to_owned()),
519            metadata: None,
520        }
521    }
522
523    /// A file part carrying its bytes inline, base64-encoded per `ProtoJSON`.
524    #[must_use]
525    pub fn file_raw(
526        raw_base64: impl Into<String>,
527        media_type: impl Into<String>,
528        filename: impl Into<String>,
529    ) -> Self {
530        Self {
531            text: None,
532            data: None,
533            raw: Some(raw_base64.into()),
534            url: None,
535            filename: Some(filename.into()),
536            media_type: Some(media_type.into()),
537            metadata: None,
538        }
539    }
540
541    /// A file part referring to bytes by URL.
542    #[must_use]
543    pub fn file_url(
544        url: impl Into<String>,
545        media_type: impl Into<String>,
546        filename: impl Into<String>,
547    ) -> Self {
548        Self {
549            text: None,
550            data: None,
551            raw: None,
552            url: Some(url.into()),
553            filename: Some(filename.into()),
554            media_type: Some(media_type.into()),
555            metadata: None,
556        }
557    }
558}
559
560/// How a skill shapes its A2A answer, when the default projection is not it.
561///
562/// The default stands for most skills: a string output becomes a text part and
563/// anything else a data part, in one artifact. What that projection cannot say
564/// is *file content* — inline bytes or a URL with a filename — or that the
565/// answer is a quick, stateless `Message` rather than a task's artifact. Both
566/// are ordinary A2A response shapes a peer may expect.
567///
568/// A skill opts in by returning this value (via [`A2aReply::into_value`]) as
569/// its outcome. The runtime journals it as it journals any output — the shape
570/// is a *projection instruction* read at the protocol boundary, not a second
571/// channel around the journal.
572///
573/// ```no_run
574/// # use agentplane::api::a2a::{A2aReply, Part};
575/// # use agentplane::core::{Outcome, Tainted};
576/// // A task whose artifact is a file reference:
577/// let reply = A2aReply::artifact(vec![Part::file_url(
578///     "https://example.com/report.pdf",
579///     "application/pdf",
580///     "report.pdf",
581/// )]);
582/// # let _ = Outcome::done(Tainted::trusted(reply.into_value()));
583/// ```
584#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
585pub struct A2aReply {
586    #[serde(skip_serializing_if = "Option::is_none")]
587    message: Option<Vec<Part>>,
588    #[serde(skip_serializing_if = "Option::is_none")]
589    artifacts: Option<Vec<Vec<Part>>>,
590}
591
592/// The output key an [`A2aReply`] travels under.
593///
594/// A `$`-prefixed marker, like `$a2a_message` on the inbound side: ordinary
595/// skill output is domain data this runtime never interprets, so the one shape
596/// it *does* interpret must be unmistakably deliberate rather than a field
597/// name a domain object happens to share.
598const REPLY_KEY: &str = "$a2a_reply";
599
600impl A2aReply {
601    /// Answer the blocking send with a direct `Message` instead of a task.
602    ///
603    /// Outside a blocking send — a spawned task, a stream, a later `GetTask` —
604    /// there is no message to deliver, and the parts become the task's
605    /// artifact instead: the content survives, only the envelope differs.
606    #[must_use]
607    pub const fn message(parts: Vec<Part>) -> Self {
608        Self {
609            message: Some(parts),
610            artifacts: None,
611        }
612    }
613
614    /// Answer with one artifact holding these parts.
615    #[must_use]
616    pub fn artifact(parts: Vec<Part>) -> Self {
617        Self {
618            message: None,
619            artifacts: Some(vec![parts]),
620        }
621    }
622
623    /// Answer with several artifacts.
624    #[must_use]
625    pub const fn artifacts(artifacts: Vec<Vec<Part>>) -> Self {
626        Self {
627            message: None,
628            artifacts: Some(artifacts),
629        }
630    }
631
632    /// The outcome value a skill returns.
633    #[must_use]
634    pub fn into_value(self) -> Value {
635        json!({ REPLY_KEY: self })
636    }
637
638    /// Read a reply back out of a run's output, **if the run may declare one**.
639    ///
640    /// A projection instruction is authority: it decides whether the answer is
641    /// a task artifact or a direct `Message`, and what parts it carries —
642    /// including a file part naming a URL. Skill output routinely *contains*
643    /// untrusted data: a summariser quotes its input, a declarative agent's
644    /// answer is a model's words, and an echoing skill returns a peer's own
645    /// bytes. So the marker is honoured only from a **trusted** output.
646    ///
647    /// Otherwise a peer could put the marker in its message, have an ordinary
648    /// echoing skill return it, and choose the envelope its own reply arrived
649    /// in — a file URL of the attacker's naming, presented as the agent's
650    /// answer. Model output is a proposal, never authority; so is a peer's
651    /// message.
652    ///
653    /// An untrusted answer still reaches the caller — as the artifact content
654    /// it is, rather than as an instruction about the envelope.
655    pub(super) fn of_output(output: &crate::core::Tainted<Value>) -> Option<Self> {
656        if output.label().is_untrusted() {
657            return None;
658        }
659        serde_json::from_value(output.peek().get(REPLY_KEY)?.clone()).ok()
660    }
661
662    /// The message parts, when this reply is a direct `Message`.
663    pub(super) fn message_parts(&self) -> Option<Vec<Part>> {
664        self.message.clone()
665    }
666
667    /// Every part, for contexts that can only carry artifacts.
668    pub(super) fn artifact_parts(&self) -> Vec<Vec<Part>> {
669        match (&self.artifacts, &self.message) {
670            (Some(artifacts), _) => artifacts.clone(),
671            (None, Some(message)) => vec![message.clone()],
672            (None, None) => Vec::new(),
673        }
674    }
675}
676
677/// A2A's `Message`.
678#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
679#[serde(rename_all = "camelCase")]
680pub struct A2aMessage {
681    pub message_id: String,
682    pub role: String,
683    pub parts: Vec<Part>,
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub context_id: Option<String>,
686    #[serde(default, skip_serializing_if = "Option::is_none")]
687    pub task_id: Option<String>,
688    #[serde(default, skip_serializing_if = "Option::is_none")]
689    pub metadata: Option<Value>,
690    #[serde(default, skip_serializing_if = "Vec::is_empty")]
691    pub extensions: Vec<String>,
692    #[serde(default, skip_serializing_if = "Vec::is_empty")]
693    pub reference_task_ids: Vec<String>,
694}
695
696impl A2aMessage {
697    fn validate_parts(&self, caller: &Caller) -> Result<(), RpcError> {
698        if self.role != "ROLE_USER" {
699            return Err(RpcError::new(
700                code::INVALID_PARAMS,
701                "an inbound Message role must be ROLE_USER",
702            ));
703        }
704        // The id is half of the admission key — an empty one would fold every
705        // caller's unnamed messages into a single "retry" of the first.
706        if self.message_id.is_empty() {
707            return Err(RpcError::new(
708                code::INVALID_PARAMS,
709                "messageId must be non-empty: it is what lets a retry of this \
710                 message be recognised as the same message",
711            ));
712        }
713        // The other half of the same key, bounded by the runtime's admission-key
714        // ceiling. The key also carries the caller's name, so the bound is on
715        // the key this caller's message makes, not on the id alone — and it is
716        // the caller's message that is refused, with a code that says so,
717        // rather than a fault the caller would retry unchanged.
718        let key = crate::core::origin_key(&admission_source(&caller.actor), &self.message_id);
719        if key.len() > crate::runtime::MAX_ADMISSION_KEY_BYTES {
720            return Err(RpcError::new(
721                code::INVALID_PARAMS,
722                format!(
723                    "messageId is too long: with the caller's identity it makes an \
724                     admission key over {} bytes",
725                    crate::runtime::MAX_ADMISSION_KEY_BYTES
726                ),
727            ));
728        }
729        if self.parts.is_empty() {
730            return Err(RpcError::new(
731                code::CONTENT_TYPE_NOT_SUPPORTED,
732                "the message has no parts this agent can read; it accepts text and data parts",
733            ));
734        }
735        for (index, part) in self.parts.iter().enumerate() {
736            // Only the file modalities are refused. A `mediaType` on a text or
737            // data part is a *label* the spec allows on every part type — a
738            // peer sending `text/markdown` text is conformant, and the card's
739            // input modes are a tailoring hint, not a per-part contract the
740            // server may bounce messages on.
741            if part.raw.is_some() || part.url.is_some() {
742                return Err(RpcError::new(
743                    code::CONTENT_TYPE_NOT_SUPPORTED,
744                    format!(
745                        "message.parts[{index}] is file content; this agent card advertises only text/plain and application/json"
746                    ),
747                ));
748            }
749            let variants = usize::from(part.text.is_some()) + usize::from(part.data.is_some());
750            if variants != 1 {
751                return Err(RpcError::new(
752                    code::INVALID_PARAMS,
753                    format!("message.parts[{index}] must contain exactly one of text or data"),
754                ));
755            }
756        }
757        Ok(())
758    }
759
760    /// What the runtime receives as input.
761    ///
762    /// A stable shape, always the same three keys, rather than a clever unwrapping
763    /// that hands a skill a bare string sometimes and an object other times. A
764    /// skill parsing its own input should not have to branch on how many parts
765    /// the caller happened to send. `$a2a_message` preserves the exact protocol
766    /// object for task-history reconstruction; `text` and `data` remain the
767    /// ergonomic projections skills normally consume.
768    fn to_input(&self) -> Value {
769        let text: Vec<&str> = self
770            .parts
771            .iter()
772            .filter_map(|p| p.text.as_deref())
773            .collect();
774        let data: Vec<Value> = self.parts.iter().filter_map(|p| p.data.clone()).collect();
775        json!({
776            "text": text.join("\n"),
777            "data": data,
778            "$a2a_message": self,
779        })
780    }
781
782    /// The skill this message asks for, if it named one.
783    fn requested_skill(&self) -> Option<&str> {
784        self.metadata.as_ref()?.get("skill")?.as_str()
785    }
786}
787
788/// A JSON-RPC 2.0 request.
789#[derive(Debug, Clone, Deserialize)]
790struct RpcRequest {
791    #[serde(default)]
792    jsonrpc: String,
793    #[serde(default)]
794    id: Value,
795    method: String,
796    #[serde(default)]
797    params: Value,
798}
799
800/// The parts of `SendMessageConfiguration` this server acts on.
801#[derive(Debug, Clone, Default, Deserialize)]
802#[serde(rename_all = "camelCase")]
803struct SendConfiguration {
804    /// Return as soon as the task exists, rather than when it finishes.
805    ///
806    /// Blocking is the spec's default and the default here: unset means wait.
807    #[serde(default)]
808    return_immediately: bool,
809    #[serde(default)]
810    accepted_output_modes: Vec<String>,
811    #[serde(default)]
812    history_length: Option<usize>,
813    #[serde(default)]
814    task_push_notification_config: Option<PushRequest>,
815}
816
817impl SendConfiguration {
818    fn validate(&self) -> Result<(), RpcError> {
819        if !self.accepted_output_modes.is_empty()
820            && !self
821                .accepted_output_modes
822                .iter()
823                .any(|mode| matches!(mode.as_str(), "text/plain" | "application/json"))
824        {
825            return Err(RpcError::new(
826                code::CONTENT_TYPE_NOT_SUPPORTED,
827                "acceptedOutputModes contains no mode this agent can produce",
828            ));
829        }
830        Ok(())
831    }
832}
833
834#[derive(Debug, Clone, Deserialize)]
835#[serde(rename_all = "camelCase")]
836struct PushAuthenticationRequest {
837    scheme: String,
838    #[serde(default)]
839    credentials: Option<String>,
840}
841
842#[derive(Debug, Clone, Deserialize)]
843#[serde(rename_all = "camelCase")]
844struct PushRequest {
845    #[serde(default)]
846    id: Option<String>,
847    #[serde(default)]
848    task_id: Option<String>,
849    url: String,
850    #[serde(default)]
851    token: Option<String>,
852    #[serde(default)]
853    authentication: Option<PushAuthenticationRequest>,
854}
855
856/// The longest push configuration id a caller may choose, in bytes.
857const MAX_PUSH_ID_LEN: usize = 128;
858
859/// The most push configurations one task holds.
860///
861/// Every one is a delivery per record, so without a ceiling a peer multiplies
862/// the plane's outbound traffic and store rows by however many it registers.
863/// Checked before the write rather than inside it, so two registrations racing
864/// for the last slot can both land; the bound is on what a peer can build up,
865/// not an exact count.
866const MAX_PUSH_CONFIGS_PER_TASK: usize = 10;
867
868impl PushRequest {
869    /// Refuse an id in the namespace an operator destination owns, or one
870    /// longer than [`MAX_PUSH_ID_LEN`].
871    ///
872    /// A caller and the deployment share one push store, and the two are told
873    /// apart by an id prefix. A caller allowed to write into that namespace could
874    /// point one of the deployment's own destinations at an address it chose —
875    /// and operator destinations are deliberately exempt from the host
876    /// allowlist, HTTPS and the public-address check, because there is supposed
877    /// to be no caller involved. This is the check that keeps that supposition
878    /// true.
879    fn validate(&self) -> Result<(), RpcError> {
880        if self
881            .id
882            .as_deref()
883            .is_some_and(|id| id.len() > MAX_PUSH_ID_LEN)
884        {
885            return Err(RpcError::new(
886                code::INVALID_PARAMS,
887                format!("a pushNotificationConfig id is at most {MAX_PUSH_ID_LEN} bytes"),
888            ));
889        }
890        if self.id.as_deref().is_some_and(crate::push::is_operator_id) {
891            return Err(RpcError::new(
892                code::INVALID_PARAMS,
893                format!(
894                    "a pushNotificationConfig id may not begin with '{}': that namespace \
895                     belongs to destinations this deployment configured for itself",
896                    crate::push::OPERATOR_PREFIX
897                ),
898            ));
899        }
900        Ok(())
901    }
902
903    fn config(&self, task: RunId) -> crate::push::PushConfig {
904        crate::push::PushConfig {
905            id: self.id.clone().unwrap_or_else(|| format!("push-{task}")),
906            task,
907            url: self.url.clone(),
908            token: self.token.clone().map(crate::core::Secret::new),
909            authentication: self.authentication.as_ref().map(|authentication| {
910                crate::push::PushAuthentication {
911                    scheme: authentication.scheme.clone(),
912                    credentials: crate::core::Secret::new(
913                        authentication.credentials.clone().unwrap_or_default(),
914                    ),
915                }
916            }),
917        }
918    }
919}
920
921/// What every method's params may carry.
922///
923/// One struct for every method, which is the same shape the binary's arguments
924/// had before they moved onto per-verb structs: a field belonging to one method
925/// was **silently accepted** by another and did nothing. On the wire that reads
926/// worse than at a command line, because the caller is a stranger who cannot
927/// see the source. `ListTasks` was the case that mattered — a request naming
928/// `contxtId`, or the `context_id` the protocol's own conformance kit sends,
929/// parsed cleanly, dropped the filter, and answered with **every** task the
930/// caller may see, shaped exactly like the scoped list that was asked for.
931///
932/// Two mechanisms close it, and they are different questions.
933/// [`deny_unknown_fields`] refuses a name this surface does not know at all
934/// (`contxtId`, `task_id`), which the A2A specification licenses
935/// outright: A2A §5.5 says JSON field names **MUST** be camelCase, so `context_id` is not an
936/// alternative spelling but a violation. [`FIELDS_BY_METHOD`] refuses a name
937/// this surface knows and *this method* does not (`pageSize` on `CancelTask`).
938///
939/// Neither subsumes the other.
940///
941/// [`deny_unknown_fields`]: https://serde.rs/container-attrs.html
942#[derive(Debug, Clone, Default, Deserialize)]
943#[serde(deny_unknown_fields)]
944struct CommonParams {
945    /// A2A's opaque routing identifier.
946    #[serde(default)]
947    tenant: Option<String>,
948    #[serde(default)]
949    message: Option<A2aMessage>,
950    #[serde(default)]
951    id: Option<String>,
952    #[serde(default)]
953    configuration: Option<SendConfiguration>,
954    /// `SendMessageRequest.metadata`, accepted and not interpreted.
955    ///
956    /// Modelled rather than ignored because unknown fields are refused, and
957    /// the two are different answers: the specification defines this field, so
958    /// a conforming client may send it and must not meet `-32602`. It is
959    /// deliberately not read — opaque caller data has no governed meaning here,
960    /// and inventing one would be a control nothing enforces. What the runtime
961    /// does record about an inbound message is its authenticated sender, which
962    /// is provenance rather than a field the sender chose.
963    ///
964    /// `dead_code` is allowed here for the one case where it is the point: the
965    /// field exists so that deserialization *accepts* the name, and reading it
966    /// is what would be wrong.
967    #[serde(default)]
968    #[allow(dead_code)]
969    metadata: Option<Value>,
970    #[serde(default, rename = "taskId")]
971    push_task: Option<String>,
972    #[serde(default)]
973    url: Option<String>,
974    #[serde(default)]
975    token: Option<String>,
976    #[serde(default)]
977    authentication: Option<PushAuthenticationRequest>,
978    #[serde(default, rename = "contextId")]
979    context_id: Option<String>,
980    #[serde(default)]
981    status: Option<TaskState>,
982    #[serde(default, rename = "pageSize")]
983    page_size: Option<usize>,
984    #[serde(default, rename = "pageToken")]
985    page_token: Option<String>,
986    #[serde(default, rename = "historyLength")]
987    history_length: Option<usize>,
988    #[serde(default, rename = "statusTimestampAfter")]
989    status_timestamp_after: Option<String>,
990    #[serde(default, rename = "includeArtifacts")]
991    include_artifacts: bool,
992}
993
994/// A JSON-RPC error, carrying the HTTP status it should be served with.
995#[derive(Debug, Clone)]
996pub struct RpcError {
997    code: i32,
998    message: String,
999    /// The id of the request being answered — `null` when it could not be read.
1000    id: Value,
1001    /// Served as HTTP 401 with a bearer challenge rather than 200.
1002    unauthenticated: bool,
1003}
1004
1005impl RpcError {
1006    fn new(code: i32, message: impl Into<String>) -> Self {
1007        Self {
1008            code,
1009            message: message.into(),
1010            id: Value::Null,
1011            unauthenticated: false,
1012        }
1013    }
1014
1015    /// A caller whose credential was refused: HTTP 401 with
1016    /// `WWW-Authenticate`, as A2A requires, so the status says *authenticate*
1017    /// rather than the body saying *malformed*.
1018    fn unauthenticated(message: impl Into<String>) -> Self {
1019        Self {
1020            unauthenticated: true,
1021            ..Self::new(code::INVALID_REQUEST, message)
1022        }
1023    }
1024
1025    fn with_id(mut self, id: Value) -> Self {
1026        self.id = id;
1027        self
1028    }
1029
1030    /// The machine-readable reason A2A 1.0's error-handling rules require
1031    /// beside an A2A-specific code, as a `google.rpc.ErrorInfo` in
1032    /// `error.data` — `(domain, reason)`.
1033    ///
1034    /// Derived from the code rather than declared per call site, because the
1035    /// two are one fact: the spec's table maps each code to exactly one reason
1036    /// token, and a site that could set them independently is a site that can
1037    /// disagree with itself. Standard JSON-RPC codes carry no reason — the
1038    /// spec assigns them none — so they return `None` and the error omits
1039    /// `data` rather than inventing a token.
1040    ///
1041    /// The spec's codes carry the spec's domain. [`code::QUOTA_EXHAUSTED`]
1042    /// carries this plane's own — [`ERROR_DOMAIN`](crate::peers::ERROR_DOMAIN)
1043    /// — and that domain is load-bearing rather than decorative: the numeric
1044    /// code sits in space A2A 1.0 reserves for its own future errors, so the
1045    /// code alone is not proof of what it means. The `(domain, reason)` pair
1046    /// is, and it is what this crate's client keys its back-off
1047    /// classification on.
1048    ///
1049    /// This adds no information a prober does not already have: the reason is
1050    /// a restatement of the code in the same response. The uniform-refusal
1051    /// rule governs what a *model* is told; this is the protocol channel to an
1052    /// authenticated peer.
1053    const fn reason(&self) -> Option<(&'static str, &'static str)> {
1054        const A2A: &str = "a2a-protocol.org";
1055        const SELF_DOMAIN: &str = crate::peers::ERROR_DOMAIN;
1056        const QUOTA_EXHAUSTED_REASON: &str = crate::peers::QUOTA_EXHAUSTED_REASON;
1057        const HALTED_REASON: &str = crate::peers::HALTED_REASON;
1058        const DRAINING_REASON: &str = crate::peers::DRAINING_REASON;
1059        match self.code {
1060            code::TASK_NOT_FOUND => Some((A2A, "TASK_NOT_FOUND")),
1061            code::TASK_NOT_CANCELABLE => Some((A2A, "TASK_NOT_CANCELABLE")),
1062            code::PUSH_NOT_SUPPORTED => Some((A2A, "PUSH_NOTIFICATION_NOT_SUPPORTED")),
1063            code::UNSUPPORTED_OPERATION => Some((A2A, "UNSUPPORTED_OPERATION")),
1064            code::CONTENT_TYPE_NOT_SUPPORTED => Some((A2A, "CONTENT_TYPE_NOT_SUPPORTED")),
1065            code::EXTENDED_CARD_NOT_CONFIGURED => Some((A2A, "EXTENDED_AGENT_CARD_NOT_CONFIGURED")),
1066            code::VERSION_NOT_SUPPORTED => Some((A2A, "VERSION_NOT_SUPPORTED")),
1067            code::QUOTA_EXHAUSTED => Some((SELF_DOMAIN, QUOTA_EXHAUSTED_REASON)),
1068            code::HALTED => Some((SELF_DOMAIN, HALTED_REASON)),
1069            code::DRAINING => Some((SELF_DOMAIN, DRAINING_REASON)),
1070            _ => None,
1071        }
1072    }
1073
1074    /// The JSON-RPC error member, with the required `ErrorInfo` when one
1075    /// applies.
1076    fn body(&self) -> Value {
1077        match self.reason() {
1078            Some((domain, reason)) => json!({
1079                "code": self.code,
1080                "message": self.message,
1081                "data": [{
1082                    "@type": "type.googleapis.com/google.rpc.ErrorInfo",
1083                    "domain": domain,
1084                    "reason": reason,
1085                }],
1086            }),
1087            None => json!({ "code": self.code, "message": self.message }),
1088        }
1089    }
1090}
1091
1092impl IntoResponse for RpcError {
1093    fn into_response(self) -> Response {
1094        // HTTP 200 with a JSON-RPC error body, except for a refused credential.
1095        // JSON-RPC carries its own error channel, and a transport-level status
1096        // for an application-level refusal is how a client ends up retrying a
1097        // permanent decline: an A2A client reads `error.code`, and many treat
1098        // a 5xx as retryable without ever parsing the body. Authentication is
1099        // the transport's question, and A2A answers it with 401.
1100        let body = Json(json!({
1101            "jsonrpc": "2.0",
1102            "id": self.id,
1103            "error": self.body(),
1104        }));
1105        if self.unauthenticated {
1106            return (
1107                StatusCode::UNAUTHORIZED,
1108                [(axum::http::header::WWW_AUTHENTICATE, "Bearer")],
1109                body,
1110            )
1111                .into_response();
1112        }
1113        (StatusCode::OK, body).into_response()
1114    }
1115}
1116
1117/// This plane, served as an A2A agent.
1118#[derive(Clone)]
1119pub struct A2aServer {
1120    runtime: Arc<Runtime>,
1121    auth: Arc<dyn Authenticator>,
1122    policy: Arc<dyn crate::core::PolicyEngine>,
1123    card: AgentCard,
1124    extended: ExtendedAgentCard,
1125    /// Each agent's own card, by agent name.
1126    per_agent: std::collections::BTreeMap<String, crate::peers::AgentCard>,
1127    /// The card's advertised skill ids — what a caller may ask for.
1128    skills: Vec<String>,
1129    push: Option<PushRuntime>,
1130    /// How many of the caller's own tasks one `ListTasks` may examine.
1131    ///
1132    /// See [`Self::filter_scan_budget`] for why this is a ceiling and not a
1133    /// page size.
1134    filter_scan_budget: usize,
1135    /// How many strict replays one `ListTasks` with `includeArtifacts` may
1136    /// perform. See [`Self::artifact_replay_budget`].
1137    artifact_replay_budget: usize,
1138    /// Artifact projections of **sealed** runs, so a poll loop does not buy a
1139    /// strict replay per read. See [`ArtifactCache`].
1140    artifact_cache: Arc<std::sync::Mutex<ArtifactCache>>,
1141    /// Open streams, per caller. See [`Self::streams_per_caller`].
1142    streams: StreamSlots,
1143}
1144
1145/// Default for [`A2aServer::streams_per_caller`].
1146const STREAMS_PER_CALLER: usize = 8;
1147
1148/// How many streams each caller holds open on this server.
1149#[derive(Debug, Clone)]
1150struct StreamSlots {
1151    limit: usize,
1152    open: Arc<std::sync::Mutex<std::collections::HashMap<String, usize>>>,
1153}
1154
1155impl StreamSlots {
1156    fn new(limit: usize) -> Self {
1157        Self {
1158            limit,
1159            open: Arc::default(),
1160        }
1161    }
1162
1163    /// One more stream for `actor`, or `None` at its ceiling.
1164    fn claim(&self, actor: &str) -> Option<StreamSlot> {
1165        let mut open = crate::core::poison::recover(&self.open);
1166        let held = open.entry(actor.to_owned()).or_default();
1167        if *held >= self.limit {
1168            return None;
1169        }
1170        *held += 1;
1171        Some(StreamSlot {
1172            open: Arc::clone(&self.open),
1173            actor: actor.to_owned(),
1174        })
1175    }
1176}
1177
1178/// One open stream, given back when the stream is dropped — whether it ended
1179/// or its client went away.
1180#[derive(Debug)]
1181pub(super) struct StreamSlot {
1182    open: Arc<std::sync::Mutex<std::collections::HashMap<String, usize>>>,
1183    actor: String,
1184}
1185
1186impl Drop for StreamSlot {
1187    fn drop(&mut self) {
1188        let mut open = crate::core::poison::recover(&self.open);
1189        if let Some(held) = open.get_mut(&self.actor) {
1190            *held = held.saturating_sub(1);
1191            if *held == 0 {
1192                open.remove(&self.actor);
1193            }
1194        }
1195    }
1196}
1197
1198/// A bounded in-process cache of sealed runs' artifact projections.
1199///
1200/// A completed task's artifacts exist only as a projection of its journal, so
1201/// every read recovers them by **strict replay** — the most expensive read
1202/// this surface performs. The cache is sound because it holds only sealed
1203/// runs: a seal is the last record a run ever gets, so the projection is a
1204/// pure function of an immutable history and can never go stale. Bounded and
1205/// evicting oldest-first, because an unbounded cache is a leak with a
1206/// performance story; insertion order is enough where every entry is equally
1207/// immutable.
1208#[derive(Debug, Default)]
1209pub(super) struct ArtifactCache {
1210    map: std::collections::HashMap<RunId, Option<Vec<A2aArtifact>>>,
1211    order: std::collections::VecDeque<RunId>,
1212}
1213
1214/// How many sealed runs' artifact projections stay cached per server.
1215const ARTIFACT_CACHE_CAPACITY: usize = 256;
1216
1217/// What one bounded artifact read produced.
1218enum ArtifactRead {
1219    /// The projection — from the cache or from one counted replay.
1220    Artifacts(Option<Vec<A2aArtifact>>),
1221    /// The request's replay budget is spent; the caller must say so.
1222    OverBudget,
1223}
1224
1225impl ArtifactCache {
1226    /// A hit, already wrapped as the read it answers.
1227    fn get(&self, run: RunId) -> Option<ArtifactRead> {
1228        self.map
1229            .get(&run)
1230            .map(|artifacts| ArtifactRead::Artifacts(artifacts.clone()))
1231    }
1232
1233    fn insert(&mut self, run: RunId, artifacts: Option<Vec<A2aArtifact>>) {
1234        if self.map.insert(run, artifacts).is_none() {
1235            self.order.push_back(run);
1236        }
1237        while self.map.len() > ARTIFACT_CACHE_CAPACITY {
1238            let Some(evicted) = self.order.pop_front() else {
1239                break;
1240            };
1241            self.map.remove(&evicted);
1242        }
1243    }
1244}
1245
1246#[derive(Debug, Clone)]
1247struct PushRuntime {
1248    store: Arc<dyn crate::push::PushStore>,
1249    transport: Arc<dyn crate::push::PushTransport>,
1250}
1251
1252/// Durable A2A webhook delivery, driven by an operator scheduler.
1253///
1254/// A name, not a type of its own: [`A2aServer::push_worker`] hands back the
1255/// [`DeliveryWorker`](crate::push::DeliveryWorker) itself, bound to the A2A
1256/// projection. The struct this alias replaced was a pure delegation shell — its
1257/// own docs said so — that re-stated the loop's retry ceiling and its default
1258/// in a second place, which is two copies of one contract with nothing holding
1259/// them together. The cursor discipline lives in `push` because it has nothing
1260/// to do with A2A; what is A2A about this worker is only the projection it was
1261/// constructed with.
1262pub type A2aPushWorker = crate::push::DeliveryWorker;
1263
1264/// Outcome of one bounded push sweep.
1265///
1266/// Re-exported from [`crate::push`], which owns the delivery loop.
1267pub use crate::push::PushSweepReport;
1268
1269/// `StreamResponse` payloads, for **caller-registered** webhooks only.
1270///
1271/// It claims exactly the registrations an operator destination does not — see
1272/// [`crate::push::Outbox`] on why the two share one store and must not serve
1273/// each other's rows.
1274#[derive(Clone)]
1275struct A2aProjection {
1276    runtime: Arc<Runtime>,
1277}
1278
1279impl std::fmt::Debug for A2aProjection {
1280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1281        f.debug_struct("A2aProjection").finish_non_exhaustive()
1282    }
1283}
1284
1285#[async_trait::async_trait]
1286impl crate::push::Projection for A2aProjection {
1287    async fn messages(
1288        &self,
1289        record: &crate::journal::Record,
1290    ) -> Result<Vec<crate::push::PushMessage>, crate::core::StoreError> {
1291        let case = record.body.case.map(|case| case.to_string());
1292        let payloads =
1293            super::a2a_stream::payloads_for_record(&self.runtime, record, case.as_deref())
1294                .await
1295                // A projection failure is this plane's own bug and always transient
1296                // to the worker; the shape it travels in is the store's error type
1297                // because that is what the seam speaks.
1298                .map_err(|error| crate::core::StoreError::Backend(error.to_string()))?;
1299        // One record can produce a status event and an artifact event, so the
1300        // record's sequence alone is not an identity. The id a receiver
1301        // deduplicates on is the task, the sequence and the position within
1302        // the record — stable across every retry of the same message, distinct
1303        // between the two.
1304        Ok(payloads
1305            .into_iter()
1306            .enumerate()
1307            .map(|(index, payload)| {
1308                crate::push::PushMessage::json(
1309                    format!("{}/{}/{index}", record.body.run, record.body.seq),
1310                    payload,
1311                )
1312                // A2A's media type, so a receiver routes push and streaming
1313                // through the same parser.
1314                .typed("application/a2a+json")
1315            })
1316            .collect())
1317    }
1318
1319    fn terminal(&self, record: &crate::journal::Record) -> bool {
1320        ends_task(record.kind())
1321    }
1322
1323    fn namespace(&self) -> PushNamespace {
1324        PushNamespace::Caller
1325    }
1326}
1327
1328/// Whether a record ends the task a peer is watching.
1329///
1330/// A conclusion in a terminal A2A state, and nothing else: a withheld run has
1331/// concluded in the journal and is interrupted on this wire, so releasing its
1332/// push registration there would leave the peer to learn how it ends from
1333/// nobody.
1334fn ends_task(kind: &RecordKind) -> bool {
1335    matches!(kind, RecordKind::RunConcluded { outcome, .. } if outcome_ends_task(outcome))
1336}
1337
1338fn outcome_ends_task(outcome: &str) -> bool {
1339    super::a2a_stream::closes(sealed_state(outcome))
1340}
1341
1342impl std::fmt::Debug for A2aServer {
1343    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1344        f.debug_struct("A2aServer")
1345            .field("agent", &self.card.name)
1346            .field("skills", &self.skills)
1347            .finish_non_exhaustive()
1348    }
1349}
1350
1351/// Why a server could not be built.
1352#[derive(Debug, thiserror::Error)]
1353pub enum ServerSetupError {
1354    #[error(
1355        "the runtime has no policy engine, so every A2A method would be \
1356         unauthorized. A surface reachable by other agents cannot be the one \
1357         place that skips the gate"
1358    )]
1359    NoPolicy,
1360    /// The policy set cannot evaluate a request this surface asks. A2A asks
1361    /// under `{roles, peer, tenant}` alone, so an unscoped rule reading
1362    /// `context.delegation_depth` or `context.label` unguarded declines every
1363    /// peer.
1364    #[error(
1365        "the policy set cannot evaluate the A2A surface's requests: {problems} — \
1366         they carry only `roles`, `peer` and `tenant`; scope the rule to the \
1367         actions it is about, or guard the read with `context has …`"
1368    )]
1369    PolicyUnevaluable { problems: String },
1370    #[error(
1371        "the runtime has no case layer, so this server cannot mint the \
1372         contextId A2A 1.0 requires on every task — a generated contextId \
1373         must be continuable, and continuation here is a case. Build the \
1374         runtime with `.cases(store)`"
1375    )]
1376    NoCases,
1377    #[error("the agent card could not be derived: {0}")]
1378    Card(#[from] crate::manifest::ManifestError),
1379    #[error("push changes the signed Agent Card; configure it before calling signing_cards_with")]
1380    CardAlreadySigned,
1381    #[error(
1382        "an A2A server serves at least one agent, and no manifest was given — the well-known card path must answer with a card describing something"
1383    )]
1384    NoAgents,
1385    #[error(
1386        "agents '{first}' and '{second}' both advertise skill '{skill}', so a request naming it would be a routing decision the caller did not make. A2A dispatch is named, never inferred: give the skill distinct capability names, or serve the two agents from separate planes"
1387    )]
1388    AmbiguousSkill {
1389        skill: String,
1390        first: String,
1391        second: String,
1392    },
1393}
1394
1395impl A2aServer {
1396    /// Serve `manifest`'s agent at `url`.
1397    ///
1398    /// `url` is where callers reach this plane, and it goes on the card — the
1399    /// same deployment-wiring split as everywhere else: an agent's declaration
1400    /// must not change when its address does.
1401    ///
1402    /// # Errors
1403    ///
1404    /// [`ServerSetupError::NoPolicy`] when the runtime has no policy engine, and
1405    /// [`ServerSetupError::Card`] when the manifest's digest cannot be computed.
1406    pub fn new(
1407        runtime: Arc<Runtime>,
1408        auth: Arc<dyn Authenticator>,
1409        security: &crate::peers::CardSecurity,
1410        manifest: &Manifest,
1411        url: impl Into<String>,
1412    ) -> Result<Self, ServerSetupError> {
1413        Self::hosting(runtime, auth, security, &[manifest], url)
1414    }
1415
1416    /// Serve several declared agents from one plane.
1417    ///
1418    /// A2A's well-known card path is singular per host, so the alternative to
1419    /// this is a server per agent. The first manifest is the one the **well-known card
1420    /// describes** — a room's orchestrator, in the shape the CLI already uses —
1421    /// and every agent additionally gets its own full card at
1422    /// [`agent_card_path`](crate::peers::agent_card_path), listed in the
1423    /// [`EXT_AGENT_DIRECTORY`](crate::peers::EXT_AGENT_DIRECTORY) extension so
1424    /// a caller can find them.
1425    ///
1426    /// Skill dispatch spans every agent, because they are all on the runtime
1427    /// already — what was missing was only discovery. Two agents advertising
1428    /// one skill id is **refused**: dispatch names a skill, and a name that
1429    /// resolves to two agents is a routing decision the caller did not make.
1430    ///
1431    /// # Errors
1432    ///
1433    /// [`ServerSetupError::NoPolicy`], [`ServerSetupError::NoCases`],
1434    /// [`ServerSetupError::NoAgents`] for an empty slice, or
1435    /// [`ServerSetupError::AmbiguousSkill`].
1436    pub fn hosting(
1437        runtime: Arc<Runtime>,
1438        auth: Arc<dyn Authenticator>,
1439        security: &crate::peers::CardSecurity,
1440        manifests: &[&Manifest],
1441        url: impl Into<String>,
1442    ) -> Result<Self, ServerSetupError> {
1443        let policy = runtime.policy().ok_or(ServerSetupError::NoPolicy)?.clone();
1444        let mut problems = policy_problems(policy.as_ref());
1445        // A peer with no chain of its own acts under none on this plane, so
1446        // the runtime's requests for it take a shape the build did not probe.
1447        problems.extend(runtime.served_policy_problems());
1448        if !problems.is_empty() {
1449            return Err(ServerSetupError::PolicyUnevaluable {
1450                problems: problems.join("; "),
1451            });
1452        }
1453        if runtime.cases().is_none() {
1454            return Err(ServerSetupError::NoCases);
1455        }
1456        let [primary, rest @ ..] = manifests else {
1457            return Err(ServerSetupError::NoAgents);
1458        };
1459        let url = url.into();
1460
1461        // Every agent's own card, derived exactly as a lone agent's would be —
1462        // same digest, same skills, same ceilings. A card that differed because
1463        // its agent shared a plane would make the plane part of the identity a
1464        // consumer pins, which the room work already refused for manifests.
1465        let mut directory = Vec::new();
1466        let mut per_agent = std::collections::BTreeMap::new();
1467        let mut owner_of_skill: std::collections::BTreeMap<String, String> =
1468            std::collections::BTreeMap::new();
1469        for m in manifests {
1470            let name = m.metadata.name.clone();
1471            let mut agent_card = AgentCard::derive(m, url.clone())?;
1472            security.apply(&mut agent_card);
1473            for skill in &agent_card.skills {
1474                if let Some(other) = owner_of_skill.insert(skill.id.clone(), name.clone())
1475                    && other != name
1476                {
1477                    return Err(ServerSetupError::AmbiguousSkill {
1478                        skill: skill.id.clone(),
1479                        first: other,
1480                        second: name,
1481                    });
1482                }
1483            }
1484            directory.push(serde_json::json!({
1485                "name": name,
1486                "version": m.metadata.version,
1487                "cardPath": crate::peers::agent_card_path(&name),
1488                "manifestDigest": m.digest()?.to_hex(),
1489                "skills": agent_card.skills.iter().map(|s| s.id.clone()).collect::<Vec<_>>(),
1490            }));
1491            per_agent.insert(name, agent_card);
1492        }
1493
1494        let mut card = AgentCard::derive(primary, url.clone())?;
1495        let mut extended = ExtendedAgentCard::derive(primary, url)?;
1496        security.apply(&mut card);
1497        security.apply(&mut extended.public);
1498
1499        // Only when there is more than one, so a single-agent plane's card is
1500        // byte-for-byte what it was: an extension nobody needs is a claim a
1501        // verifier has to understand for nothing.
1502        if !rest.is_empty() {
1503            let ext = crate::peers::AgentExtension {
1504                uri: crate::peers::EXT_AGENT_DIRECTORY.to_owned(),
1505                description: Some(
1506                    "Every agent this plane serves, and where each agent's own card is.".to_owned(),
1507                ),
1508                required: false,
1509                params: Some(serde_json::json!({ "agents": directory })),
1510            };
1511            card.capabilities.extensions.push(ext.clone());
1512            extended.public.capabilities.extensions.push(ext);
1513        }
1514
1515        // The union, because every agent is already on the runtime and a skill
1516        // that dispatches but is not accepted here would be a refusal the plane
1517        // could have answered.
1518        let skills = owner_of_skill.keys().cloned().collect();
1519        let tenant = runtime.tenant().to_string();
1520        let mut server = Self {
1521            runtime,
1522            auth,
1523            policy,
1524            card,
1525            extended,
1526            per_agent,
1527            skills,
1528            push: None,
1529            filter_scan_budget: FILTER_SCAN_BUDGET,
1530            artifact_replay_budget: ARTIFACT_REPLAY_BUDGET,
1531            artifact_cache: Arc::new(std::sync::Mutex::new(ArtifactCache::default())),
1532            streams: StreamSlots::new(STREAMS_PER_CALLER),
1533        };
1534        // A card names the tenant only when there is one to route on. A2A's
1535        // rule is that a client echoes this value back in every request, so
1536        // advertising `default` would make every caller send a routing
1537        // identifier that routes nowhere.
1538        if tenant != crate::core::TenantId::DEFAULT {
1539            for card in server.published_cards() {
1540                for iface in &mut card.supported_interfaces {
1541                    iface.tenant = Some(tenant.clone());
1542                }
1543            }
1544        }
1545        Ok(server)
1546    }
1547
1548    /// Every card this server publishes: the well-known card, the extended
1549    /// card's public half, and each agent's own.
1550    ///
1551    /// The deployment facts — tenant, push capability, signature — go on all of
1552    /// them through this one iterator. An agent's card is fetched and pinned
1553    /// exactly as the well-known one is, so one that lacked the tenant would
1554    /// route a client nowhere, one that lacked the push flag would hide a
1555    /// capability the server honours, and one left unsigned would be the copy
1556    /// an attacker substitutes.
1557    fn published_cards(&mut self) -> impl Iterator<Item = &mut AgentCard> {
1558        [&mut self.card, &mut self.extended.public]
1559            .into_iter()
1560            .chain(self.per_agent.values_mut())
1561    }
1562
1563    /// Bound how many streams one caller may hold open at once.
1564    ///
1565    /// A `SendStreamingMessage` or `SubscribeToTask` is a connection held for
1566    /// as long as the run lives and a journal read per poll interval — so an
1567    /// unbounded count is a cost any authenticated peer chooses for this
1568    /// plane. Past the ceiling a new stream is refused as back-pressure
1569    /// ([`code::QUOTA_EXHAUSTED`]), before anything is admitted; one ending,
1570    /// or its client going away, frees the slot.
1571    #[must_use]
1572    pub fn streams_per_caller(mut self, limit: usize) -> Self {
1573        self.streams = StreamSlots::new(limit.max(1));
1574        self
1575    }
1576
1577    /// Bound what one `ListTasks` may cost.
1578    ///
1579    /// The spec's `totalSize` is the exact total, and a `status` or `contextId`
1580    /// filter is decided only by reading each candidate's journal — so the
1581    /// cost of a listing is one the *caller* chooses.
1582    ///
1583    /// The budget counts the caller's own tasks. The index a listing reads is
1584    /// narrowed to them, so another caller's volume — or the embedder's — does
1585    /// not refuse this one's listing.
1586    ///
1587    /// Over budget, the request is refused with the narrowing lever named —
1588    /// `statusTimestampAfter` is answered from the index, so tightening it
1589    /// shrinks the candidate set without reading anything. A refusal is honest
1590    /// where a truncated total would be a lie shaped like an answer: the spec
1591    /// requires the exact count, and a bound that quietly stopped counting
1592    /// would report a smaller tenant, not a bounded scan.
1593    #[must_use]
1594    pub fn filter_scan_budget(mut self, budget: usize) -> Self {
1595        self.filter_scan_budget = budget.max(1);
1596        self
1597    }
1598
1599    /// Bound what one `ListTasks` with `includeArtifacts` may cost.
1600    ///
1601    /// A completed task's artifacts are recovered by strict replay, and a page
1602    /// holds up to a hundred tasks — so without a ceiling, one authenticated
1603    /// request buys a hundred full replays, per request, forever. Replays
1604    /// served from the sealed-run cache cost nothing and do not count; past
1605    /// the budget a task's artifacts are **omitted and marked** (see
1606    /// [`ARTIFACTS_OMITTED_KEY`]) rather than the request refused, because
1607    /// unlike `totalSize` an artifact list is per-task enrichment: omitting
1608    /// one is honest as long as it does not look complete, and `GetTask` on
1609    /// the marked id recovers exactly that task's artifacts.
1610    #[must_use]
1611    pub fn artifact_replay_budget(mut self, budget: usize) -> Self {
1612        self.artifact_replay_budget = budget.max(1);
1613        self
1614    }
1615
1616    /// The single-task read, where one replay is the request's stated cost.
1617    ///
1618    /// A separate entry point rather than `artifacts_bounded(.., None)`,
1619    /// because the caller that passes no budget cannot be handed
1620    /// [`ArtifactRead::OverBudget`] — and saying that with `unreachable!` in a
1621    /// request handler makes a caller's argument the only thing between a
1622    /// JSON-RPC method and a panic. Here the type says it instead.
1623    async fn artifacts_unmetered(
1624        &self,
1625        run: RunId,
1626        state: TaskState,
1627    ) -> Result<Option<Vec<A2aArtifact>>, crate::core::RuntimeError> {
1628        match self.artifacts_bounded(run, state, None).await? {
1629            ArtifactRead::Artifacts(artifacts) => Ok(artifacts),
1630            // Unreachable by construction — no budget, nothing to exceed — and
1631            // answered rather than asserted: an empty artifact list is what a
1632            // task with none looks like, which is the honest reading of "this
1633            // read produced none".
1634            ArtifactRead::OverBudget => Ok(None),
1635        }
1636    }
1637
1638    /// A task's artifacts, replaying at most once per sealed run.
1639    ///
1640    /// Consults [`ArtifactCache`] first and fills it for completed runs; the
1641    /// uncached fall-through is [`task_artifacts`]. `budget` is decremented on
1642    /// every actual replay; `None` means unmetered, which reaches here only
1643    /// through [`artifacts_unmetered`](Self::artifacts_unmetered).
1644    ///
1645    /// A budget hit comes back as [`ArtifactRead::OverBudget`], never as an
1646    /// absent list, so the caller must decide how to mark it — silence here
1647    /// would be a bounded result shaped exactly like a complete one.
1648    async fn artifacts_bounded(
1649        &self,
1650        run: RunId,
1651        state: TaskState,
1652        budget: Option<&mut usize>,
1653    ) -> Result<ArtifactRead, crate::core::RuntimeError> {
1654        if state != TaskState::Completed {
1655            return Ok(ArtifactRead::Artifacts(None));
1656        }
1657        if let Some(cached) = crate::core::poison::recover(&self.artifact_cache).get(run) {
1658            return Ok(cached);
1659        }
1660        if let Some(budget) = &budget
1661            && **budget == 0
1662        {
1663            return Ok(ArtifactRead::OverBudget);
1664        }
1665        let artifacts = task_artifacts(&self.runtime, run, state).await?;
1666        if let Some(budget) = budget {
1667            *budget -= 1;
1668        }
1669        crate::core::poison::recover(&self.artifact_cache).insert(run, artifacts.clone());
1670        Ok(ArtifactRead::Artifacts(artifacts))
1671    }
1672
1673    /// Publish a **signed** card.
1674    ///
1675    /// The card is served unauthenticated from a host a caller may not control.
1676    /// TLS says the bytes came from that host; it says nothing about whether the
1677    /// host is the party whose capabilities the card describes. A signature says
1678    /// that, and it keeps saying it after the card has been copied into a
1679    /// registry, a cache, or somebody's repository.
1680    ///
1681    /// Signed here rather than at derivation because the signature covers the
1682    /// **published** card, interface URL and tenant included — those are
1683    /// deployment facts, and a signature taken before they were set would cover
1684    /// a document nobody serves.
1685    ///
1686    /// # Errors
1687    ///
1688    /// If the card cannot be canonicalized.
1689    pub fn signing_cards_with(
1690        mut self,
1691        signer: &dyn crate::peers::CardSigner,
1692    ) -> Result<Self, crate::peers::CardSignatureError> {
1693        for card in self.published_cards() {
1694            card.sign(signer)?;
1695        }
1696        Ok(self)
1697    }
1698
1699    /// Enable A2A push configuration and expose a durable delivery worker.
1700    ///
1701    /// The worker uses the task journal itself as its outbox. A registration's
1702    /// cursor advances only after a receiver returns 2xx, so a crash between
1703    /// POST and acknowledgement persistence repeats an event and never loses
1704    /// one. Call [`A2aServer::push_worker`] before consuming the server into its
1705    /// router and schedule
1706    /// [`DeliveryWorker::run_once`](crate::push::DeliveryWorker::run_once) from
1707    /// every instance.
1708    pub fn with_push(
1709        mut self,
1710        store: Arc<dyn crate::push::PushStore>,
1711        transport: Arc<dyn crate::push::PushTransport>,
1712    ) -> Result<Self, ServerSetupError> {
1713        if self
1714            .published_cards()
1715            .any(|card| !card.signatures.is_empty())
1716        {
1717            return Err(ServerSetupError::CardAlreadySigned);
1718        }
1719        for card in self.published_cards() {
1720            card.capabilities.push_notifications = true;
1721        }
1722        self.push = Some(PushRuntime { store, transport });
1723        Ok(self)
1724    }
1725
1726    /// A cloneable worker handle, when push is configured.
1727    ///
1728    /// The worker is [`crate::push::DeliveryWorker`] itself, bound to the A2A
1729    /// projection — rows belonging to an operator
1730    /// [`Outbox`](crate::push::Outbox) are left alone, because the projection
1731    /// declares the caller namespace and the store's due query filters on it.
1732    /// Retry ceiling, backoff and abandonment reporting are the loop's own;
1733    /// see its docs rather than a restatement here.
1734    #[must_use]
1735    pub fn push_worker(&self) -> Option<A2aPushWorker> {
1736        self.push.clone().map(|push| {
1737            crate::push::DeliveryWorker::new(
1738                Arc::clone(self.runtime.journal()),
1739                push.store,
1740                push.transport,
1741                Arc::new(A2aProjection {
1742                    runtime: Arc::clone(&self.runtime),
1743                }),
1744            )
1745        })
1746    }
1747
1748    /// The router.
1749    ///
1750    /// The card path is unauthenticated by design and everything else is not.
1751    ///
1752    /// The RPC endpoint answers with and without a trailing slash, and that is
1753    /// an interoperability fact rather than a courtesy: mainstream HTTP clients
1754    /// that take a base URL — httpx among them, which is what the official A2A
1755    /// conformance kit and the reference Python SDK are built on — resolve a
1756    /// request for `"/"` against the card's interface URL per RFC 3986, so the
1757    /// wire carries `POST {interface}/`. A router that 404s the slash form
1758    /// passes every test written here and fails the first real peer. Exactness
1759    /// elsewhere (method names, version headers) guards protocol *semantics*;
1760    /// a trailing slash is a client-side join artifact with none.
1761    pub fn router(self) -> Router {
1762        Router::new()
1763            .route(WELL_KNOWN_PATH, get(agent_card))
1764            // Unauthenticated like the well-known card, and for the same
1765            // reason: a card a caller must already be trusted to read is a card
1766            // that cannot be discovered.
1767            .route("/agents/{agent}/agent-card.json", get(one_agent_card))
1768            .route("/a2a", post(rpc))
1769            .route("/a2a/", post(rpc))
1770            .with_state(self)
1771    }
1772
1773    /// Find the task, and refuse it unless this caller admitted it — then
1774    /// authorize, with the owner in the rule's context.
1775    ///
1776    /// A task id is a bearer of nothing. Policy decides what a caller may do
1777    /// with *its* tasks; whose task an id names is decided here, before policy
1778    /// is asked, and a task another peer admitted — or one nobody admitted over
1779    /// this surface — answers exactly as a task that does not exist. Anything
1780    /// else tells a peer holding a guessed or leaked id that it is real.
1781    ///
1782    /// Hands back the task's journal, read once, because every caller of this
1783    /// was about to read it anyway.
1784    async fn gate_task(
1785        &self,
1786        caller: &Caller,
1787        action: &str,
1788        params: &CommonParams,
1789    ) -> Result<(RunId, Vec<crate::journal::Record>), RpcError> {
1790        let run = task_id(params)?;
1791        let records = self.authorized_task(caller, action, run).await?;
1792        Ok((run, records))
1793    }
1794
1795    /// [`gate_task`](Self::gate_task) for a task id already parsed.
1796    async fn authorized_task(
1797        &self,
1798        caller: &Caller,
1799        action: &str,
1800        run: RunId,
1801    ) -> Result<Vec<crate::journal::Record>, RpcError> {
1802        let records = self
1803            .runtime
1804            .journal()
1805            .read(run, 1)
1806            .await
1807            .map_err(|e| internal("reading a task's journal", &e))?;
1808        let owner = task_owner(&records);
1809        if owner != Some(caller.actor.as_str()) {
1810            return Err(task_not_found(run));
1811        }
1812        self.authorize(caller, action, &run.to_string(), owner)?;
1813        Ok(records)
1814    }
1815
1816    /// Who is calling, from the credential — and only if it is this tenant.
1817    async fn authenticate(&self, headers: &HeaderMap) -> Result<Caller, RpcError> {
1818        let caller = self
1819            .auth
1820            .authenticate(headers)
1821            .await
1822            .map_err(|e| RpcError::unauthenticated(e.to_string()))?;
1823        // The caller's tenant is the second half of the check `check_tenant`
1824        // starts. That one compares what the *request* asked for against what
1825        // the card advertises; this compares it against what the *credential*
1826        // says. A peer that authenticates into one tenant must not be served
1827        // from another's runs by naming it in a field, and a peer holding a
1828        // valid credential for a different tenant is exactly who would try.
1829        // Refused as a credential, in the same sentence as any other this
1830        // endpoint does not accept: naming the tenant would tell a prober the
1831        // token is valid somewhere else.
1832        if caller.tenant != *self.runtime.tenant() {
1833            return Err(RpcError::unauthenticated(
1834                crate::api::AuthError::Rejected.to_string(),
1835            ));
1836        }
1837        Ok(caller)
1838    }
1839
1840    /// What a rule on this surface can key on.
1841    ///
1842    /// `owner` is present on a task action: the peer that admitted the task,
1843    /// which by the time a rule is asked is always the caller — so a rule set
1844    /// can say "a peer reads its own tasks" in its own words rather than trust
1845    /// this surface to have said it.
1846    fn context(caller: &Caller, owner: Option<&str>) -> Value {
1847        let mut context = peer_context(caller);
1848        if let Some(owner) = owner {
1849            context["owner"] = json!(owner);
1850        }
1851        context
1852    }
1853
1854    /// The policy's answer for this caller, action and resource — the one
1855    /// request both [`authorize`](Self::authorize) and
1856    /// [`permits`](Self::permits) ask.
1857    fn decision(
1858        &self,
1859        caller: &Caller,
1860        action: &str,
1861        resource: &str,
1862        owner: Option<&str>,
1863    ) -> PolicyDecision {
1864        let context = Self::context(caller, owner);
1865        self.policy.authorize(&PolicyRequest {
1866            principal: &caller.actor,
1867            principal_kind: crate::core::PrincipalKind::Subject,
1868            action,
1869            resource,
1870            context: &context,
1871        })
1872    }
1873
1874    fn authorize(
1875        &self,
1876        caller: &Caller,
1877        action: &str,
1878        resource: &str,
1879        owner: Option<&str>,
1880    ) -> Result<(), RpcError> {
1881        match self.decision(caller, action, resource, owner) {
1882            PolicyDecision::Permit => Ok(()),
1883            PolicyDecision::Deny { reason } => {
1884                // The determining policy and its reason stay operator-side. A
1885                // Cedar denial names the action, the resource, and the policy
1886                // ids that fired; returned to an external caller that is a
1887                // probe-able map of the authorization vocabulary. A decline
1888                // carries no reason on the wire — the runtime's own denial
1889                // already names the action and resource the gate keyed on. The
1890                // caller learns only that it was declined; the reason reaches
1891                // whoever runs the plane.
1892                tracing::warn!(
1893                    target: "agentplane::a2a",
1894                    action,
1895                    resource,
1896                    reason,
1897                    "A2A request denied at admission"
1898                );
1899                Err(RpcError::new(
1900                    code::INVALID_REQUEST,
1901                    "this request was not permitted",
1902                ))
1903            }
1904            // The rules could not be evaluated. The caller is declined
1905            // exactly as for a denial — telling a peer that the plane's
1906            // policy set is broken is a fact about our operations, and a
1907            // probe would learn which shapes break it — but the operator's
1908            // side says defect rather than refusal, because the fix is the
1909            // policy set and nobody should be reading rules looking for the
1910            // one that fired.
1911            PolicyDecision::Malformed { reason } => {
1912                tracing::error!(
1913                    target: "agentplane::a2a",
1914                    action,
1915                    resource,
1916                    policy_error = true,
1917                    reason,
1918                    "A2A request refused because the policy set could not be evaluated"
1919                );
1920                Err(RpcError::new(
1921                    code::INVALID_REQUEST,
1922                    "this request was not permitted",
1923                ))
1924            }
1925        }
1926    }
1927
1928    fn permits(&self, caller: &Caller, action: &str, resource: &str, owner: Option<&str>) -> bool {
1929        matches!(
1930            self.decision(caller, action, resource, owner),
1931            PolicyDecision::Permit
1932        )
1933    }
1934
1935    /// Refuse a version this server does not speak.
1936    ///
1937    /// An **absent** header is a refusal, not a default. The spec says an empty
1938    /// value means 0.3, so a missing header is a 0.3 client — and answering it
1939    /// with 1.0 semantics is how a caller silently loses half the protocol.
1940    /// Matching is on `Major.Minor`, as the spec requires.
1941    fn check_version(headers: &HeaderMap) -> Result<(), RpcError> {
1942        let claimed = headers
1943            .get(VERSION_HEADER)
1944            .and_then(|v| v.to_str().ok())
1945            .unwrap_or("");
1946        let claimed_version = crate::peers::protocol_major_minor(claimed);
1947        if claimed_version == crate::peers::protocol_major_minor(crate::peers::PROTOCOL_VERSION)
1948            && claimed_version.is_some()
1949        {
1950            return Ok(());
1951        }
1952        let seen = if claimed.is_empty() {
1953            "0.3 (no A2A-Version header, which the spec reads as 0.3)".to_owned()
1954        } else {
1955            claimed.to_owned()
1956        };
1957        Err(RpcError::new(
1958            code::VERSION_NOT_SUPPORTED,
1959            format!(
1960                "this agent speaks A2A {}, and the request asked for {seen}",
1961                crate::peers::PROTOCOL_VERSION
1962            ),
1963        ))
1964    }
1965
1966    /// Refuse a request routed to a different tenant.
1967    ///
1968    /// A2A's rule is that the client echoes the `tenant` from the interface it
1969    /// selected on the card, omitting it when the card omits it. So a value that
1970    /// does not match ours is a request meant for somebody else — plausibly
1971    /// another plane behind the same address — and answering it would serve one
1972    /// tenant's caller from another tenant's runs.
1973    fn check_tenant(&self, params: &CommonParams) -> Result<(), RpcError> {
1974        let ours = self.runtime.tenant().as_str();
1975        let advertised = if ours == crate::core::TenantId::DEFAULT {
1976            ""
1977        } else {
1978            ours
1979        };
1980        let sent = params.tenant.as_deref().unwrap_or("");
1981        if sent == advertised {
1982            return Ok(());
1983        }
1984        Err(RpcError::new(
1985            code::INVALID_PARAMS,
1986            format!(
1987                "this endpoint serves the tenant advertised on its card, and \
1988                 the request named '{sent}'. A2A clients echo the `tenant` from \
1989                 the interface they selected; a different value is a request \
1990                 for a different agent"
1991            ),
1992        ))
1993    }
1994}
1995
1996/// The public Agent Card.
1997///
1998/// Unauthenticated on purpose — see the module docs. It is derived from the
1999/// manifest, so it cannot describe a capability the plane would refuse to
2000/// dispatch, which is what makes publishing it safe.
2001async fn agent_card(State(server): State<A2aServer>) -> Json<AgentCard> {
2002    Json(server.card)
2003}
2004
2005/// One agent's own card.
2006///
2007/// A 404 for an unknown name, deliberately naming nothing: the directory
2008/// extension on the well-known card is how a caller learns which names exist,
2009/// and answering an unknown one with the list would make this path a way to
2010/// enumerate a plane without reading the card that governs it.
2011async fn one_agent_card(
2012    State(server): State<A2aServer>,
2013    axum::extract::Path(agent): axum::extract::Path<String>,
2014) -> Response {
2015    server.per_agent.get(&agent).map_or_else(
2016        || (StatusCode::NOT_FOUND, "no such agent on this plane").into_response(),
2017        |card| Json(card.clone()).into_response(),
2018    )
2019}
2020
2021async fn rpc(
2022    State(server): State<A2aServer>,
2023    headers: HeaderMap,
2024    body: Result<Json<RpcRequest>, axum::extract::rejection::JsonRejection>,
2025) -> Response {
2026    let req = match body {
2027        Ok(Json(req)) => req,
2028        // Each refusal is its own spec row, and they must not share a code: a
2029        // body that is not `application/json` is `ContentTypeNotSupported`
2030        // (-32005) in A2A's own mapping table; a body that is not JSON at all
2031        // is JSON-RPC's ParseError; valid JSON that is not a request object —
2032        // a batch array, a missing `method` — is InvalidRequest. Collapsing
2033        // them tells a caller with a wrong header or a wrong shape that its
2034        // serializer is broken. A body over the size limit is not a JSON-RPC
2035        // question and keeps its HTTP 413.
2036        Err(rejection) => {
2037            use axum::extract::rejection::JsonRejection;
2038            let error = match &rejection {
2039                JsonRejection::MissingJsonContentType(_) => RpcError::new(
2040                    code::CONTENT_TYPE_NOT_SUPPORTED,
2041                    "the request body must be application/json",
2042                ),
2043                JsonRejection::JsonDataError(_) => RpcError::new(
2044                    code::INVALID_REQUEST,
2045                    "the request body is JSON but not a JSON-RPC request object",
2046                ),
2047                JsonRejection::BytesRejection(_) => return rejection.into_response(),
2048                _ => RpcError::new(code::PARSE_ERROR, "the request body is not valid JSON"),
2049            };
2050            return error.into_response();
2051        }
2052    };
2053    // JSON-RPC's id is a string, a number or null. Echoing an object or an
2054    // array back would hand the caller an id its own client cannot match.
2055    if !matches!(req.id, Value::Null | Value::String(_) | Value::Number(_)) {
2056        return RpcError::new(
2057            code::INVALID_REQUEST,
2058            "`id` must be a string, a number or null",
2059        )
2060        .into_response();
2061    }
2062    let id = req.id.clone();
2063
2064    if req.jsonrpc != "2.0" {
2065        return RpcError::new(
2066            code::INVALID_REQUEST,
2067            format!("`jsonrpc` must be \"2.0\", not {:?}", req.jsonrpc),
2068        )
2069        .with_id(id)
2070        .into_response();
2071    }
2072    if let Err(e) = A2aServer::check_version(&headers) {
2073        return e.with_id(id).into_response();
2074    }
2075    // Before anything about the request is read. Parameters, the tenant field
2076    // and the method name all answer differently depending on what was sent,
2077    // and an unauthenticated caller is owed the challenge and nothing it could
2078    // map the surface with.
2079    let caller = match server.authenticate(&headers).await {
2080        Ok(caller) => caller,
2081        Err(e) => return e.with_id(id).into_response(),
2082    };
2083
2084    // Streaming methods are dispatched first because they answer with a
2085    // different *kind* of response: an SSE body, not a JSON-RPC envelope. Folding
2086    // them into `dispatch` would mean a function whose return type is "a value
2087    // or an entire HTTP response", which is how one of the two paths quietly
2088    // stops setting its content type.
2089    if matches!(
2090        req.method.as_str(),
2091        method::SEND_STREAMING | method::SUBSCRIBE
2092    ) {
2093        return match stream_method(server, caller, req).await {
2094            Ok(sse) => sse.into_response(),
2095            Err(e) => e.with_id(id).into_response(),
2096        };
2097    }
2098
2099    // **Boxed, because this future is the whole runtime.** `dispatch` fans out
2100    // to every A2A method and one of them admits and executes a run, so the
2101    // state machine inlined here carries the executor's own locals — sixteen
2102    // kilobytes of them. Held on the stack it is sixteen kilobytes *per
2103    // concurrent request*, paid by every caller including the ones asking for a
2104    // task's status. One heap allocation per request buys that back, and a
2105    // request that reaches this line is already doing far more work than an
2106    // allocation.
2107    match Box::pin(dispatch(&server, &caller, &req)).await {
2108        Ok(result) => Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response(),
2109        Err(e) => e.with_id(id).into_response(),
2110    }
2111}
2112
2113/// `SendStreamingMessage` and `SubscribeToTask`.
2114///
2115/// Both are the same thing once the run exists: a view of the journal from a
2116/// point onward. The only difference is whether this call is what created it.
2117#[allow(clippy::too_many_lines)]
2118async fn stream_method(
2119    server: A2aServer,
2120    caller: Caller,
2121    req: RpcRequest,
2122) -> Result<
2123    axum::response::sse::Sse<
2124        impl futures_util::stream::Stream<
2125            Item = Result<axum::response::sse::Event, std::convert::Infallible>,
2126        >,
2127    >,
2128    RpcError,
2129> {
2130    let params = parse_params(&req.method, &req.params)?;
2131    server.check_tenant(&params)?;
2132    if req.method == method::SEND_STREAMING
2133        && let Some(configuration) = &params.configuration
2134    {
2135        configuration.validate()?;
2136    }
2137
2138    // A slot before anything else is done for this caller: a stream is a
2139    // connection held open and a journal polled for as long as the run lives,
2140    // so how many one peer may hold is a bound this surface states. Taken
2141    // before admission, so a caller at its ceiling starts no work it then
2142    // cannot watch; released when the stream ends or the client goes away.
2143    let slot = server
2144        .streams
2145        .claim(&caller.actor)
2146        .ok_or_else(|| RpcError::new(code::QUOTA_EXHAUSTED, QUOTA_EXHAUSTED_MESSAGE))?;
2147
2148    let (run, records) = if req.method == method::SUBSCRIBE {
2149        let id = task_id(&params)?;
2150        let records = server
2151            .authorized_task(&caller, action::TASK_READ, id)
2152            .await?;
2153        (id, records)
2154    } else {
2155        let Some(message) = params.message.clone() else {
2156            return Err(RpcError::new(
2157                code::INVALID_PARAMS,
2158                "`message` is required by SendStreamingMessage",
2159            ));
2160        };
2161        message.validate_parts(&caller)?;
2162        refuse_push_on_continuation(&message, params.configuration.as_ref())?;
2163        let run = if message.task_id.is_some() {
2164            continue_task(&server, &caller, &message).await?
2165        } else {
2166            let skill = resolve_skill(&server, &message)?;
2167            server.authorize(&caller, action::MESSAGE_SEND, &skill, None)?;
2168            check_context(&server, &message, &caller).await?;
2169            if let Some(push) = params
2170                .configuration
2171                .as_ref()
2172                .and_then(|configuration| configuration.task_push_notification_config.as_ref())
2173            {
2174                validate_inline_push(&server, &caller, &skill, push)?;
2175            }
2176            // Admitted before the stream opens. A stream that begins and *then*
2177            // reports a refusal has already told the client the work started —
2178            // and an SSE body cannot carry a JSON-RPC error the client is looking
2179            // for at that point.
2180            let source = super::peer_source(&caller.actor);
2181            let input = Tainted::from_source(message.to_input(), SourceId::new(&source));
2182            let terms = run_terms(&message, &caller)?;
2183            match spawn_a2a(&server, &skill, input, terms).await {
2184                Ok(run) => {
2185                    if let Some(push) = params.configuration.as_ref().and_then(|configuration| {
2186                        configuration.task_push_notification_config.as_ref()
2187                    }) {
2188                        register_push(&server, push, run, 1).await?;
2189                    }
2190                    run
2191                }
2192                Err(
2193                    crate::core::RuntimeError::PolicyDenied(_)
2194                    | crate::core::RuntimeError::Delegation(_),
2195                ) => {
2196                    return Err(RpcError::new(
2197                        code::UNSUPPORTED_OPERATION,
2198                        "this agent declined the request",
2199                    ));
2200                }
2201                Err(crate::core::RuntimeError::QuotaExceeded(e)) => {
2202                    return Err(quota_refusal(&e));
2203                }
2204                Err(crate::core::RuntimeError::Draining) => {
2205                    return Err(RpcError::new(code::DRAINING, DRAINING_MESSAGE));
2206                }
2207                Err(e @ crate::core::RuntimeError::SubjectUnbound { .. }) => {
2208                    return Err(subject_unbound(&e));
2209                }
2210                Err(e) => return Err(internal("admitting a streamed message", &e)),
2211            }
2212        };
2213        let records = server
2214            .runtime
2215            .journal()
2216            .read(run, 1)
2217            .await
2218            .map_err(|e| internal("reading a task's journal", &e))?;
2219        (run, records)
2220    };
2221
2222    // Decided from the records before anything is replayed: a subscription
2223    // to a finished task is refused, and refusing it after a strict replay to
2224    // build artifacts nobody will receive is a cost the caller chose for us.
2225    let Some((state, detail)) = state_from_history(&records) else {
2226        return Err(task_not_found(run));
2227    };
2228    // `closes` and not a second `matches!` with the same four states: this
2229    // decides whether a subscription is *refused*, and `closes` decides whether
2230    // a stream *ends*. Two spellings of one rule disagree the day somebody adds
2231    // a terminal state to one of them, and the result is either a subscription
2232    // accepted that shuts immediately or one refused that would have streamed.
2233    if req.method == method::SUBSCRIBE && super::a2a_stream::closes(state) {
2234        return Err(RpcError::new(
2235            code::UNSUPPORTED_OPERATION,
2236            "SubscribeToTask requires a non-terminal task",
2237        ));
2238    }
2239    let case = records
2240        .iter()
2241        .find_map(|r| r.body.case.map(|c| c.to_string()));
2242    let from = records.last().map_or(1, |last| last.body.seq + 1);
2243    let mut task = task_of(run, state, &detail, case.clone());
2244    task.artifacts = server
2245        .artifacts_unmetered(run, state)
2246        .await
2247        .map_err(|e| internal("projecting a task's artifacts", &e))?;
2248
2249    Ok(super::a2a_stream::tail(
2250        Arc::clone(&server.runtime),
2251        Arc::clone(&server.artifact_cache),
2252        slot,
2253        run,
2254        case,
2255        req.id,
2256        task,
2257        from,
2258    ))
2259}
2260
2261async fn dispatch(
2262    server: &A2aServer,
2263    caller: &Caller,
2264    req: &RpcRequest,
2265) -> Result<Value, RpcError> {
2266    let params = parse_params(&req.method, &req.params)?;
2267    server.check_tenant(&params)?;
2268
2269    match req.method.as_str() {
2270        // Boxed: a send admits and may run a whole run inline, and that future
2271        // is larger than the others this match holds.
2272        method::SEND_MESSAGE => Box::pin(send_message(server, caller, params)).await,
2273        method::GET_TASK => get_task(server, caller, params).await,
2274        method::CANCEL_TASK => cancel_task(server, caller, params).await,
2275        method::GET_EXTENDED_CARD => get_extended_card(server, caller),
2276
2277        // Streaming is handled before dispatch; reaching here means the router
2278        // changed and this arm did not.
2279        method::SEND_STREAMING | method::SUBSCRIBE => Err(RpcError::new(
2280            code::INTERNAL_ERROR,
2281            "a streaming method reached the non-streaming dispatcher",
2282        )),
2283        method::LIST_TASKS => list_tasks(server, caller, &params).await,
2284        method::CREATE_PUSH => push_create(server, caller, &params).await,
2285        method::GET_PUSH => push_get(server, caller, &params).await,
2286        method::LIST_PUSH => push_list(server, caller, &params).await,
2287        method::DELETE_PUSH => push_delete(server, caller, &params).await,
2288        other => Err(RpcError::new(
2289            code::METHOD_NOT_FOUND,
2290            format!("no such A2A method: {other}"),
2291        )),
2292    }
2293}
2294
2295/// Which parameter names each method actually reads.
2296///
2297/// `tenant` is on every row rather than special-cased: A2A's routing identifier
2298/// is orthogonal to the method, and leaving it out of one row would refuse a
2299/// correctly-routed request for that method alone — the kind of hole a table
2300/// exists to make visible.
2301const FIELDS_BY_METHOD: &[(&str, &[&str])] = &[
2302    (
2303        method::SEND_MESSAGE,
2304        &["tenant", "message", "configuration", "metadata"],
2305    ),
2306    (
2307        method::SEND_STREAMING,
2308        &["tenant", "message", "configuration", "metadata"],
2309    ),
2310    (method::GET_TASK, &["tenant", "id", "historyLength"]),
2311    (method::CANCEL_TASK, &["tenant", "id"]),
2312    (method::SUBSCRIBE, &["tenant", "id"]),
2313    (method::GET_EXTENDED_CARD, &["tenant"]),
2314    (
2315        method::LIST_TASKS,
2316        &[
2317            "tenant",
2318            "contextId",
2319            "status",
2320            "pageSize",
2321            "pageToken",
2322            "historyLength",
2323            "statusTimestampAfter",
2324            "includeArtifacts",
2325        ],
2326    ),
2327    (
2328        method::CREATE_PUSH,
2329        &["tenant", "taskId", "id", "url", "token", "authentication"],
2330    ),
2331    (method::GET_PUSH, &["tenant", "taskId", "id"]),
2332    (
2333        method::LIST_PUSH,
2334        &["tenant", "taskId", "pageSize", "pageToken"],
2335    ),
2336    (method::DELETE_PUSH, &["tenant", "taskId", "id"]),
2337];
2338
2339fn parse_params(method: &str, value: &Value) -> Result<CommonParams, RpcError> {
2340    if value.is_null() {
2341        return Ok(CommonParams::default());
2342    }
2343    let Some(object) = value.as_object() else {
2344        return Err(RpcError::new(
2345            code::INVALID_PARAMS,
2346            "A2A method parameters must be a JSON object",
2347        ));
2348    };
2349
2350    // Before deserializing, because the union struct would accept the field and
2351    // the method would then ignore it. An unknown method falls through to the
2352    // dispatcher's own `METHOD_NOT_FOUND`, which is a better answer than a
2353    // parameter complaint about a method that does not exist.
2354    if let Some((_, allowed)) = FIELDS_BY_METHOD.iter().find(|(m, _)| *m == method)
2355        && let Some(stray) = object.keys().find(|k| !allowed.contains(&k.as_str()))
2356    {
2357        return Err(RpcError::new(
2358            code::INVALID_PARAMS,
2359            format!(
2360                "'{stray}' is not a parameter of {method}; it takes {}",
2361                allowed.join(", ")
2362            ),
2363        ));
2364    }
2365
2366    serde_json::from_value(value.clone()).map_err(|error| {
2367        RpcError::new(
2368            code::INVALID_PARAMS,
2369            format!("request parameters do not match the A2A method schema: {error}"),
2370        )
2371    })
2372}
2373
2374/// Answer a message aimed at a task that has already sealed.
2375///
2376/// Decided from the task's own journal: the event store's subscription may
2377/// already be retired, and routing a retransmit through `deliver_to` would
2378/// answer a legitimate retry with a terminal-state error. The journal records
2379/// every delivered continuation message verbatim, so the retry of the message
2380/// that completed the task is recognisable there — and a *new* message aimed
2381/// at a finished task stays refused.
2382fn continue_sealed_task(
2383    run: RunId,
2384    records: &[crate::journal::Record],
2385    message: &A2aMessage,
2386) -> Result<RunId, RpcError> {
2387    let delivered_before = records.iter().any(|record| {
2388        matches!(
2389            record.kind(),
2390            RecordKind::EffectDone { output, .. }
2391                if output
2392                    .get("$a2a_message")
2393                    .and_then(|m| m.get("messageId"))
2394                    .and_then(Value::as_str)
2395                    == Some(message.message_id.as_str())
2396        )
2397    });
2398    if delivered_before {
2399        return Ok(run);
2400    }
2401    Err(RpcError::new(
2402        code::UNSUPPORTED_OPERATION,
2403        "this task has completed and is not waiting for input",
2404    ))
2405}
2406
2407/// Refuse a push configuration on a message that continues a task.
2408///
2409/// A continuation answers with the task it continued, and registering a
2410/// webhook on that task is `CreateTaskPushNotificationConfig`'s job, under its
2411/// own gate. Accepting the field here and doing nothing with it would tell the
2412/// caller a webhook was registered that never will be.
2413fn refuse_push_on_continuation(
2414    message: &A2aMessage,
2415    configuration: Option<&SendConfiguration>,
2416) -> Result<(), RpcError> {
2417    if message.task_id.is_some()
2418        && configuration.is_some_and(|c| c.task_push_notification_config.is_some())
2419    {
2420        return Err(RpcError::new(
2421            code::INVALID_PARAMS,
2422            "configuration.taskPushNotificationConfig is not accepted on a message that \
2423             continues a task; register it with CreateTaskPushNotificationConfig",
2424        ));
2425    }
2426    Ok(())
2427}
2428
2429/// Continue one interrupted A2A task with a client message.
2430///
2431/// The run remains append-only: the message becomes the output of the exact
2432/// `event.await` effect on which the task stopped, then ordinary resume replay
2433/// carries execution forward. `EventStore::deliver_to` is task-addressed and
2434/// atomic, so another run sharing the same business correlation key cannot
2435/// consume this message.
2436async fn continue_task(
2437    server: &A2aServer,
2438    caller: &Caller,
2439    message: &A2aMessage,
2440) -> Result<RunId, RpcError> {
2441    let raw = message
2442        .task_id
2443        .as_deref()
2444        .ok_or_else(|| RpcError::new(code::INVALID_PARAMS, "`taskId` is required"))?;
2445    let run = RunId::parse(raw)
2446        .map_err(|_| RpcError::new(code::TASK_NOT_FOUND, format!("no such task: {raw}")))?;
2447    let records = server
2448        .authorized_task(caller, action::TASK_CONTINUE, run)
2449        .await?;
2450    let Some(last) = records.last() else {
2451        return Err(task_not_found(run));
2452    };
2453    if matches!(
2454        last.kind(),
2455        RecordKind::RunSuspended {
2456            reason: crate::core::SuspendReason::AwaitingTime { .. }
2457        }
2458    ) {
2459        return Err(RpcError::new(
2460            code::UNSUPPORTED_OPERATION,
2461            "this task is sleeping until a timer fires and cannot accept input",
2462        ));
2463    }
2464    if !matches!(
2465        last.kind(),
2466        RecordKind::RunSuspended { .. } | RecordKind::RunConcluded { .. }
2467    ) {
2468        return Err(RpcError::new(
2469            code::UNSUPPORTED_OPERATION,
2470            "this task is not waiting for input",
2471        ));
2472    }
2473    if let RecordKind::RunConcluded { outcome, .. } = last.kind() {
2474        if sealed_state(outcome) == TaskState::AuthRequired {
2475            return Err(RpcError::new(
2476                code::UNSUPPORTED_OPERATION,
2477                "this task is paused because the authority it acts under was withdrawn; \
2478                 it continues when this deployment restores it, not on a message",
2479            ));
2480        }
2481        return continue_sealed_task(run, &records, message);
2482    }
2483    // Search history rather than only the last record so a transport retry of
2484    // the message that completed a task can still be recognized as the same
2485    // `(source, messageId)` and return the current task instead of a spurious
2486    // terminal-task error. A different message id finds no live subscription
2487    // and remains refused.
2488    let (kind, correlation) = records
2489        .iter()
2490        .rev()
2491        .find_map(|record| match record.kind() {
2492            RecordKind::RunSuspended {
2493                reason:
2494                    crate::core::SuspendReason::AwaitingEvent {
2495                        kind, correlation, ..
2496                    },
2497            } => Some((kind.clone(), correlation.clone())),
2498            _ => None,
2499        })
2500        .ok_or_else(|| {
2501            RpcError::new(
2502                code::UNSUPPORTED_OPERATION,
2503                "this task has no input wait to continue",
2504            )
2505        })?;
2506    let context = records
2507        .iter()
2508        .find_map(|record| record.body.case.map(|case| case.to_string()));
2509    if let Some(sent) = message.context_id.as_deref()
2510        && context.as_deref() != Some(sent)
2511    {
2512        return Err(RpcError::new(
2513            code::INVALID_PARAMS,
2514            "message.contextId does not match the referenced task",
2515        ));
2516    }
2517    // The task gate says this peer may continue its own task; this one says it
2518    // may supply the kind the task awaits.
2519    server.authorize(caller, action::EVENT_DELIVER, &kind, Some(&caller.actor))?;
2520
2521    let event = crate::core::InboundEvent {
2522        // `peer:{actor}`, the one spelling a counterparty's provenance has on
2523        // every transport — see [`super::peer_source`]. This source becomes the
2524        // delivered value's provenance, so a transport-qualified variant here
2525        // would give one counterparty two names.
2526        source: super::peer_source(&caller.actor),
2527        id: message.message_id.clone(),
2528        kind,
2529        correlation,
2530        payload: message.to_input(),
2531        // A counterparty's message, so nobody on this plane minted it. The
2532        // sender is provenance, not an operator, and the one thing this field
2533        // must not become is somewhere a peer can claim to be a person here.
2534        by: None,
2535    };
2536    match server.runtime.deliver_to(run, &event).await {
2537        // `Buffered` is a success too: when the owner's lease is stalled the
2538        // input is held durably and the sweep resumes the run — an internal
2539        // error here would tell the caller a continuation failed that
2540        // actually succeeded-pending.
2541        Ok(
2542            crate::core::Delivery::Resumed { .. }
2543            | crate::core::Delivery::Duplicate
2544            | crate::core::Delivery::Buffered,
2545        ) => Ok(run),
2546        Err(crate::core::RuntimeError::PlanContract(_)) => Err(RpcError::new(
2547            code::UNSUPPORTED_OPERATION,
2548            "this task is no longer waiting for input",
2549        )),
2550        // A run waiting on a human task waits for the worklist's answer, and a
2551        // message addressed to it by task id is not one.
2552        Err(crate::core::RuntimeError::ReservedEventKind { .. }) => Err(RpcError::new(
2553            code::UNSUPPORTED_OPERATION,
2554            "this task is waiting for a decision on this plane's worklist, not for input",
2555        )),
2556        Err(error) => Err(internal("delivering a continuation", &error)),
2557    }
2558}
2559
2560#[allow(clippy::too_many_lines)]
2561async fn send_message(
2562    server: &A2aServer,
2563    caller: &Caller,
2564    params: CommonParams,
2565) -> Result<Value, RpcError> {
2566    if let Some(configuration) = &params.configuration {
2567        configuration.validate()?;
2568    }
2569    let inline_push = params
2570        .configuration
2571        .as_ref()
2572        .and_then(|configuration| configuration.task_push_notification_config.clone());
2573    let Some(message) = params.message else {
2574        return Err(RpcError::new(
2575            code::INVALID_PARAMS,
2576            "`message` is required by SendMessage",
2577        ));
2578    };
2579    message.validate_parts(caller)?;
2580    refuse_push_on_continuation(&message, params.configuration.as_ref())?;
2581    if message.task_id.is_some() {
2582        let history_length = params
2583            .configuration
2584            .as_ref()
2585            .and_then(|configuration| configuration.history_length);
2586        let run = continue_task(server, caller, &message).await?;
2587        return get_task(
2588            server,
2589            caller,
2590            CommonParams {
2591                id: Some(run.to_string()),
2592                history_length,
2593                ..CommonParams::default()
2594            },
2595        )
2596        .await
2597        .map(|task| json!({ "task": task }));
2598    }
2599    let skill = resolve_skill(server, &message)?;
2600    server.authorize(caller, action::MESSAGE_SEND, &skill, None)?;
2601    check_context(server, &message, caller).await?;
2602    if let Some(push) = &inline_push {
2603        validate_inline_push(server, caller, &skill, push)?;
2604    }
2605
2606    // Untrusted, and provenanced to the peer that sent it. A protected sink
2607    // field can then name the one counterparty it will take an amount from, and
2608    // a skill that wants to act on this has to pass a gate to do it.
2609    let source = super::peer_source(&caller.actor);
2610    let input = Tainted::from_source(message.to_input(), SourceId::new(&source));
2611    let terms = run_terms(&message, caller)?;
2612
2613    // Non-blocking: the spec requires returning as soon as the task exists,
2614    // with an in-progress state, leaving the caller to poll `GetTask`. Admission
2615    // still happens synchronously, so a refusal is still an immediate answer —
2616    // returning a task id for a run the gate rejected would hand the caller a
2617    // handle to nothing and turn a decline into a task that never appears.
2618    if params
2619        .configuration
2620        .as_ref()
2621        .is_some_and(|c| c.return_immediately)
2622    {
2623        return match spawn_a2a(server, &skill, input, terms).await {
2624            Ok(run) => {
2625                if let Some(push) = &inline_push {
2626                    register_push(server, push, run, 1).await?;
2627                }
2628                let case = task_context(server, run).await?;
2629                Ok(json!({
2630                    "task": task_of(run, TaskState::Working, "accepted", case)
2631                }))
2632            }
2633            Err(
2634                crate::core::RuntimeError::PolicyDenied(_)
2635                | crate::core::RuntimeError::Delegation(_),
2636            ) => Ok(json!({ "message": declined(&skill) })),
2637            Err(crate::core::RuntimeError::QuotaExceeded(e)) => Err(quota_refusal(&e)),
2638            Err(crate::core::RuntimeError::Draining) => {
2639                Err(RpcError::new(code::DRAINING, DRAINING_MESSAGE))
2640            }
2641            Err(e @ crate::core::RuntimeError::SubjectUnbound { .. }) => Err(subject_unbound(&e)),
2642            Err(e) => Err(internal("admitting a message", &e)),
2643        };
2644    }
2645
2646    let admission = match run_a2a(server, &skill, input, terms).await {
2647        Ok(admission) => admission,
2648        // A policy denial is the agent *declining*, not the agent breaking.
2649        // Reported as `-32603 Internal error` it reads as "this server is
2650        // faulty, retry later", and the caller retries a decision that will
2651        // never change.
2652        //
2653        // Answered as a `Message` rather than a rejected `Task` because no task
2654        // exists: nothing was admitted, so there is no id to poll and no
2655        // history to fetch. A2A's response is a oneof for exactly this.
2656        Err(
2657            crate::core::RuntimeError::PolicyDenied(_) | crate::core::RuntimeError::Delegation(_),
2658        ) => {
2659            return Ok(json!({ "message": declined(&skill) }));
2660        }
2661        // Back-pressure, not a fault. `-32603` reads as "the far side is
2662        // broken, retry later" and a caller may well retry the same second;
2663        // this says *the agent cannot take this on right now*, which is what a
2664        // ceiling means and what a caller should back off from. The code and
2665        // message are fixed — see `code::QUOTA_EXHAUSTED` for why neither is
2666        // the spec's `-32004` and why the quota arithmetic stays out of it.
2667        Err(crate::core::RuntimeError::QuotaExceeded(e)) => {
2668            return Err(quota_refusal(&e));
2669        }
2670        // Not a fault and not back-pressure from the agent: this process is
2671        // going away and admitted nothing. A caller retrying now reaches a
2672        // different instance and is served.
2673        Err(crate::core::RuntimeError::Draining) => {
2674            return Err(RpcError::new(code::DRAINING, DRAINING_MESSAGE));
2675        }
2676        // The message, not the server: it lacks what the agent's declaration
2677        // reads its data subject from, and the same message is refused again.
2678        Err(e @ crate::core::RuntimeError::SubjectUnbound { .. }) => {
2679            return Err(subject_unbound(&e));
2680        }
2681        Err(e) => return Err(internal("admitting a message", &e)),
2682    };
2683    let outcome = match admission {
2684        crate::runtime::Admission::Fresh(outcome)
2685        | crate::runtime::Admission::Replayed(outcome) => outcome,
2686        // Another instance is executing this message right now. The honest
2687        // answer to a retry is *accepted, already in progress* — a Working
2688        // task naming the run — never a retry-provoking failure.
2689        crate::runtime::Admission::InFlight(run) => {
2690            let case = task_context(server, run).await?;
2691            return Ok(json!({
2692                "task": task_of(run, TaskState::Working, "accepted", case)
2693            }));
2694        }
2695    };
2696
2697    let case = task_context(server, outcome.run_id).await?;
2698    // A declared `Message` reply answers the blocking send directly — A2A's
2699    // response is a oneof for exactly this, and it is the only path with a
2700    // caller still waiting to hand a message to. Returned before push
2701    // registration, because a response with no task has no task to push about;
2702    // the run and its journal exist either way.
2703    if matches!(outcome.status, crate::runtime::RunStatus::Succeeded)
2704        && let Some(reply) = outcome.output.as_ref().and_then(A2aReply::of_output)
2705        && let Some(parts) = reply.message_parts()
2706    {
2707        return Ok(json!({
2708            "message": A2aMessage {
2709                message_id: format!("reply-{}", outcome.run_id),
2710                role: "ROLE_AGENT".to_owned(),
2711                parts,
2712                context_id: case,
2713                task_id: None,
2714                metadata: None,
2715                extensions: Vec::new(),
2716                reference_task_ids: Vec::new(),
2717            }
2718        }));
2719    }
2720    if let Some(push) = &inline_push {
2721        register_push(server, push, outcome.run_id, 1).await?;
2722    }
2723    let mut task = task_of_outcome(&outcome);
2724    task.context_id = case;
2725    // Unset means the full (capped) history, per the protocol's default —
2726    // only an explicit `0` skips the read.
2727    let history_length = params
2728        .configuration
2729        .as_ref()
2730        .and_then(|configuration| configuration.history_length);
2731    if history_length != Some(0) {
2732        let records = server
2733            .runtime
2734            .journal()
2735            .read(outcome.run_id, 1)
2736            .await
2737            .map_err(|_| {
2738                RpcError::new(code::INTERNAL_ERROR, "the task journal could not be read")
2739            })?;
2740        task.history = task_history(
2741            outcome.run_id,
2742            &records,
2743            history_length,
2744            task.context_id.as_deref(),
2745        );
2746    }
2747    Ok(json!({ "task": task }))
2748}
2749
2750async fn run_a2a(
2751    server: &A2aServer,
2752    skill: &str,
2753    input: Tainted<Value>,
2754    (key, terms): (String, crate::runtime::RunTerms),
2755) -> Result<crate::runtime::Admission, crate::core::RuntimeError> {
2756    server
2757        .runtime
2758        .start(skill, input)
2759        .terms(terms)
2760        .once(key)
2761        .run()
2762        .await
2763}
2764
2765async fn spawn_a2a(
2766    server: &A2aServer,
2767    skill: &str,
2768    input: Tainted<Value>,
2769    (key, terms): (String, crate::runtime::RunTerms),
2770) -> Result<RunId, crate::core::RuntimeError> {
2771    // Producer-scoped and deduplicating, for the reasons `run_terms` states.
2772    // A duplicate answers with the run the key already admitted, which is
2773    // exactly what a retrying caller needs back.
2774    Ok(server
2775        .runtime
2776        .start(skill, input)
2777        .terms(terms)
2778        .once(key)
2779        .spawn()
2780        .await?
2781        .run)
2782}
2783
2784/// The key and terms every A2A admission runs under: keyed by the producer-scoped
2785/// message id, inside the case the caller named or a fresh one, and **acting
2786/// as the caller** — under the chain its credential carried, or under none.
2787///
2788/// The key is also what makes the run this caller's: its source half is the
2789/// authenticated sender, so every later read or write of the task can ask
2790/// whose it is without a second record — see [`task_owner`].
2791///
2792/// The key carries its producer, in
2793/// [`origin_key`](crate::core::origin_key)'s spelling: a bare `messageId` would
2794/// let two counterparties swallow each other's messages as apparent retries —
2795/// or *join* each other's cases, since correlation matches any open case in the
2796/// tenant. And it is an **admission** key, not only a correlation key: this
2797/// crate's own client keeps `messageId` stable across retries precisely so a
2798/// peer can deduplicate, and a server that correlates without deduplicating
2799/// starts a second run inside the right case. A different key space from an
2800/// event's, and the same construction, because the forgery it refuses is the
2801/// same one.
2802///
2803/// The chain is the caller's, never the plane's: a plane that admitted every
2804/// peer's run under its own chain would answer "on whose behalf" with the same
2805/// name for all of them, and act for a caller whose credential permits less. A
2806/// caller that presented no chain acts under **none**: the plane's chain
2807/// bounds what it may be admitted for, and lends it none of its authority —
2808/// the fallback would make the operator's authority ambient.
2809fn run_terms(
2810    message: &A2aMessage,
2811    caller: &Caller,
2812) -> Result<(String, crate::runtime::RunTerms), RpcError> {
2813    let keyed = crate::core::origin_key(&admission_source(&caller.actor), &message.message_id);
2814    // The authenticated peer, never a body field: the four-eyes exclusion
2815    // reads it, and a caller who could name it could name somebody else.
2816    let terms =
2817        crate::runtime::RunTerms::served(caller.acting_as.clone()).admitted_by(&caller.actor);
2818    let terms = match message.context_id.as_deref() {
2819        Some(context) => {
2820            // `check_context` has already refused this, in the same words; the
2821            // parse is repeated because the terms need the typed id.
2822            let case = crate::core::CaseId::parse(context).map_err(|_| {
2823                RpcError::new(code::TASK_NOT_FOUND, format!("no such context: {context}"))
2824            })?;
2825            terms.in_case(case)
2826        }
2827        // Always correlated: `A2aServer::new` refuses a runtime without a case
2828        // layer, so every task gets a real, continuable context — the
2829        // contextId returned is one a client can send back, not a string that
2830        // satisfies a schema and continues nothing.
2831        None => terms.correlated(
2832            "a2a.context",
2833            &[crate::core::CorrelationKey::new(
2834                CONTEXT_KEY_NAMESPACE,
2835                &keyed,
2836            )],
2837        ),
2838    };
2839    Ok((keyed, terms))
2840}
2841
2842/// The `source` half of every A2A admission key: this surface, then the
2843/// authenticated sender.
2844///
2845/// Its own namespace rather than the bare [`peer_source`](super::peer_source)
2846/// an inbound event's key carries, because ownership is read back from it: an
2847/// embedder that keys a run with an event's `dedup_key` — the key this crate
2848/// recommends — must not thereby hand that run to the event's sender as an A2A
2849/// task.
2850fn admission_source(actor: &str) -> String {
2851    format!("{ADMISSION_NAMESPACE}{}", super::peer_source(actor))
2852}
2853
2854const ADMISSION_NAMESPACE: &str = "a2a/";
2855
2856/// The peer that admitted a task over this surface, read from the run's
2857/// admission key. `None` for a run nobody admitted here.
2858fn task_owner(records: &[crate::journal::Record]) -> Option<&str> {
2859    records
2860        .first()?
2861        .admission_source()?
2862        .strip_prefix(ADMISSION_NAMESPACE)?
2863        .strip_prefix("peer:")
2864}
2865
2866/// The one answer for a task this caller may not address, whether it exists
2867/// or not.
2868fn task_not_found(run: RunId) -> RpcError {
2869    RpcError::new(code::TASK_NOT_FOUND, format!("no such task: {run}"))
2870}
2871
2872/// A fault on this plane's side, told to the peer in one fixed sentence —
2873/// see [`withheld_fault`](crate::core::withheld_fault).
2874fn internal(doing: &str, error: &dyn std::fmt::Display) -> RpcError {
2875    RpcError::new(
2876        code::INTERNAL_ERROR,
2877        crate::core::withheld_fault("a2a", doing, error),
2878    )
2879}
2880
2881/// Refuse a `contextId` this caller did not open.
2882///
2883/// A context is a case, and joining one puts this caller's run beside another
2884/// peer's work and its case state in reach. So the case must be one a message
2885/// from this caller opened — its correlation key carries the admitting
2886/// sender — and anything else answers as a context that does not exist.
2887async fn check_context(
2888    server: &A2aServer,
2889    message: &A2aMessage,
2890    caller: &Caller,
2891) -> Result<(), RpcError> {
2892    let Some(context) = message.context_id.as_deref() else {
2893        return Ok(());
2894    };
2895    let not_found = || RpcError::new(code::TASK_NOT_FOUND, format!("no such context: {context}"));
2896    let case = crate::core::CaseId::parse(context).map_err(|_| not_found())?;
2897    let Some(cases) = server.runtime.cases() else {
2898        return Err(not_found());
2899    };
2900    let Some(case) = cases
2901        .case(case)
2902        .await
2903        .map_err(|e| internal("reading a context's case", &e))?
2904    else {
2905        return Err(not_found());
2906    };
2907    let ours = admission_source(&caller.actor);
2908    let opened_by_caller = case.correlation.iter().any(|key| {
2909        key.namespace == CONTEXT_KEY_NAMESPACE
2910            && crate::core::origin_source(&key.value) == Some(ours.as_str())
2911    });
2912    if !opened_by_caller {
2913        return Err(not_found());
2914    }
2915    // Refused here rather than by admission: the runtime's refusal of a closed
2916    // case is a contract error shared with every other misuse, and reading
2917    // this one out of it would answer them all as "no such context".
2918    if case.status.is_closed() {
2919        return Err(RpcError::new(
2920            code::TASK_NOT_FOUND,
2921            format!("context {context} is closed and accepts no new task"),
2922        ));
2923    }
2924    Ok(())
2925}
2926
2927/// The correlation namespace a fresh A2A context is opened under.
2928const CONTEXT_KEY_NAMESPACE: &str = "a2a-message";
2929
2930async fn task_context(server: &A2aServer, run: RunId) -> Result<Option<String>, RpcError> {
2931    server
2932        .runtime
2933        .journal()
2934        .read(run, 1)
2935        .await
2936        .map_err(|_| RpcError::new(code::INTERNAL_ERROR, "the task journal could not be read"))
2937        .map(|records| {
2938            records
2939                .iter()
2940                .find_map(|record| record.body.case.map(|case| case.to_string()))
2941        })
2942}
2943
2944pub(super) fn task_of_outcome(outcome: &crate::runtime::RunOutcome) -> A2aTask {
2945    // A declared reply carries its own parts; otherwise the default projection
2946    // stands — a string is a text part, anything else a data part.
2947    let artifacts_parts: Vec<Vec<Part>> =
2948        match outcome.output.as_ref().and_then(A2aReply::of_output) {
2949            Some(reply) => reply.artifact_parts(),
2950            None => vec![vec![match outcome
2951                .output
2952                .as_ref()
2953                .map_or(Value::Null, |o| o.peek().clone())
2954            {
2955                Value::String(text) => Part::text(text),
2956                data => Part::data(data),
2957            }]],
2958        };
2959    A2aTask {
2960        id: outcome.run_id.to_string(),
2961        context_id: None,
2962        status: TaskStatus {
2963            state: state_of(&outcome.status),
2964            message: None,
2965            timestamp: None,
2966        },
2967        artifacts: matches!(outcome.status, crate::runtime::RunStatus::Succeeded).then(|| {
2968            artifacts_parts
2969                .into_iter()
2970                .enumerate()
2971                .map(|(i, parts)| A2aArtifact {
2972                    artifact_id: format!("{}-result-{i}", outcome.run_id),
2973                    name: None,
2974                    description: None,
2975                    parts,
2976                    metadata: None,
2977                    extensions: Vec::new(),
2978                })
2979                .collect()
2980        }),
2981        history: None,
2982        metadata: None,
2983    }
2984}
2985
2986/// Which capability this message asks for.
2987///
2988/// Named or unambiguous — never inferred. See the module docs: picking a
2989/// capability from the content of an untrusted message is a dispatch decision
2990/// made by reading attacker-controlled text.
2991fn resolve_skill(server: &A2aServer, message: &A2aMessage) -> Result<String, RpcError> {
2992    if let Some(asked) = message.requested_skill() {
2993        if server.skills.iter().any(|s| s == asked) {
2994            return Ok(asked.to_owned());
2995        }
2996        return Err(RpcError::new(
2997            code::INVALID_PARAMS,
2998            format!(
2999                "this agent has no skill '{asked}'. Its card advertises: {}",
3000                server.skills.join(", ")
3001            ),
3002        ));
3003    }
3004    match server.skills.as_slice() {
3005        [only] => Ok(only.clone()),
3006        [] => Err(RpcError::new(
3007            code::UNSUPPORTED_OPERATION,
3008            "this agent advertises no skills, so there is nothing to send a \
3009             message to",
3010        )),
3011        many => Err(RpcError::new(
3012            code::INVALID_PARAMS,
3013            format!(
3014                "this agent advertises {} skills, so `message.metadata.skill` \
3015                 must name one of: {}. It is not inferred from the message — \
3016                 choosing what to run by reading the text would let the sender \
3017                 pick the capability",
3018                many.len(),
3019                many.join(", ")
3020            ),
3021        )),
3022    }
3023}
3024
3025async fn get_task(
3026    server: &A2aServer,
3027    caller: &Caller,
3028    params: CommonParams,
3029) -> Result<Value, RpcError> {
3030    let (id, records) = server.gate_task(caller, action::TASK_READ, &params).await?;
3031    load_task(server, id, &records, params.history_length).await
3032}
3033
3034async fn load_task(
3035    server: &A2aServer,
3036    id: RunId,
3037    records: &[crate::journal::Record],
3038    history_length: Option<usize>,
3039) -> Result<Value, RpcError> {
3040    // No records is no such task: a run this plane never admitted and a task id
3041    // a caller invented are the same fact from here.
3042    let (state, detail) = state_from_history(records).ok_or_else(|| task_not_found(id))?;
3043    let case = records
3044        .iter()
3045        .find_map(|r| r.body.case.map(|c| c.to_string()));
3046
3047    let mut task = task_of(id, state, &detail, case.clone());
3048    task.history = task_history(id, records, history_length, case.as_deref());
3049    // Unmetered: one task, one replay, and the sealed-run cache spares the
3050    // poll loop that reads the same finished task every few seconds.
3051    task.artifacts = server
3052        .artifacts_unmetered(id, state)
3053        .await
3054        .map_err(|error| internal("projecting a task's artifacts", &error))?;
3055    serde_json::to_value(task).map_err(|error| internal("encoding a task", &error))
3056}
3057
3058#[derive(Debug, Serialize, Deserialize)]
3059#[serde(rename_all = "camelCase")]
3060struct TaskCursor {
3061    updated_at: u64,
3062    run: String,
3063    context_id: Option<String>,
3064    status: Option<TaskState>,
3065    status_timestamp_after: Option<String>,
3066    /// How many matching tasks the earlier pages delivered.
3067    ///
3068    /// A continuation scans only from the cursor on, so without this its
3069    /// `totalSize` counts what is left rather than the listing's total, and the
3070    /// number shrinks page by page. Carried rather than recounted because a
3071    /// recount re-reads every task before the cursor — the cost the scan budget
3072    /// exists to bound. The token is the caller's, so a value it edits misstates
3073    /// only its own total.
3074    before: u64,
3075}
3076
3077/// How many index rows `list_tasks` pulls per store round trip.
3078///
3079/// Bounded so no path holds the tenant's whole index in memory — which is what
3080/// the unbounded read this replaced did on *every* call, before going on to
3081/// read the complete journal of every run it returned.
3082const TASK_SCAN: usize = 256;
3083
3084/// Default for [`A2aServer::filter_scan_budget`]: the most candidates one
3085/// `ListTasks` may examine before being refused as too broad.
3086///
3087/// The number is a cost ceiling, not a result limit — results are bounded by
3088/// `pageSize` already. At the default, the worst request a peer can make costs
3089/// on the order of a thousand journal reads, once, and is told how to narrow;
3090/// without it the cost was every run the tenant ever wrote, per request,
3091/// forever.
3092const FILTER_SCAN_BUDGET: usize = 1024;
3093
3094/// Default for [`A2aServer::artifact_replay_budget`]: the most strict replays
3095/// one `ListTasks` with `includeArtifacts` may perform.
3096///
3097/// A cost ceiling in the same spirit as [`FILTER_SCAN_BUDGET`], for the other
3098/// caller-priced read: each completed task's artifacts are a full strict
3099/// replay, a page holds up to a hundred tasks, and the sealed-run cache means
3100/// only first sight of a run pays it. Sixteen replays is a bounded worst case
3101/// per request; a page that wanted more marks the remainder omitted, and a
3102/// second request finds the first sixteen cached.
3103const ARTIFACT_REPLAY_BUDGET: usize = 16;
3104
3105/// The task-metadata key marking artifacts withheld by the replay budget.
3106///
3107/// A bounded result must not be shaped like a complete one — a silent
3108/// truncation reads as an answer. A
3109/// task past the budget is returned without artifacts — indistinguishable from
3110/// several honest states — so the omission says its own name, in `Task.metadata`
3111/// because that is the field A2A gives a server for exactly this kind of
3112/// annotation; a new top-level response member would be a schema no client
3113/// expects. `GetTask` on the marked id recovers the artifacts, and warms the
3114/// cache doing it.
3115pub const ARTIFACTS_OMITTED_KEY: &str = "io.agentplane.a2a/artifactsOmitted";
3116
3117#[allow(clippy::too_many_lines)]
3118async fn list_tasks(
3119    server: &A2aServer,
3120    caller: &Caller,
3121    params: &CommonParams,
3122) -> Result<Value, RpcError> {
3123    server.authorize(caller, action::TASK_READ, "tasks", None)?;
3124    let page_size = params.page_size.unwrap_or(50);
3125    if !(1..=100).contains(&page_size) {
3126        return Err(RpcError::new(
3127            code::INVALID_PARAMS,
3128            "pageSize must be between 1 and 100",
3129        ));
3130    }
3131    let after = params
3132        .status_timestamp_after
3133        .as_deref()
3134        .map(|value| {
3135            time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339)
3136                .map_err(|_| {
3137                    RpcError::new(
3138                        code::INVALID_PARAMS,
3139                        "statusTimestampAfter must be an RFC 3339 timestamp",
3140                    )
3141                })
3142        })
3143        .transpose()?;
3144    let cursor = params
3145        .page_token
3146        .as_deref()
3147        .map(decode_task_cursor)
3148        .transpose()?;
3149    if let Some(cursor) = &cursor
3150        && (cursor.context_id != params.context_id
3151            || cursor.status != params.status
3152            || cursor.status_timestamp_after != params.status_timestamp_after)
3153    {
3154        return Err(RpcError::new(
3155            code::INVALID_PARAMS,
3156            "pageToken was issued for different ListTasks filters",
3157        ));
3158    }
3159
3160    // Whether the caller asked anything that can only be answered by reading a
3161    // run's whole journal. `statusTimestampAfter` is not one of those — it
3162    // compares against the activity index's own timestamp.
3163    //
3164    // The candidates are the caller's own runs and nobody else's: the index
3165    // is narrowed to the admission source this surface keys the caller's runs
3166    // with, so another peer's runs and the embedder's cost this listing
3167    // nothing. An unfiltered listing reads the whole journal only of the
3168    // tasks on the page; a content-filtered one reads the whole journal of
3169    // every task it must examine.
3170    let content_filtered = params.context_id.is_some() || params.status.is_some();
3171    let source = admission_source(&caller.actor);
3172
3173    let mut cursor_pos = cursor
3174        .as_ref()
3175        .and_then(|c| RunId::parse(&c.run).ok().map(|run| (c.updated_at, run)));
3176    let mut page: Vec<(RunId, u64, A2aTask)> = Vec::new();
3177    let mut matched: u64 = 0;
3178    let mut has_more = false;
3179    let mut examined: usize = 0;
3180
3181    'scan: loop {
3182        let batch = server
3183            .runtime
3184            .journal()
3185            .recent_runs_from(&source, cursor_pos, TASK_SCAN)
3186            .await
3187            .map_err(|e| internal("reading the task index", &e))?;
3188        if batch.is_empty() {
3189            break;
3190        }
3191        cursor_pos = batch.last().map(|(run, updated)| (*updated, *run));
3192
3193        for (run, updated) in batch {
3194            if after.is_some_and(|cutoff| {
3195                i64::try_from(updated)
3196                    .ok()
3197                    .and_then(|seconds| time::OffsetDateTime::from_unix_timestamp(seconds).ok())
3198                    .is_none_or(|value| value < cutoff)
3199            }) {
3200                // The index is newest first, so every row after this one is
3201                // older still: the cutoff ends the scan, not just this row.
3202                break 'scan;
3203            }
3204
3205            // Whose task it is was settled by the index: every run in this
3206            // range was admitted under the caller's source.
3207            let owner = Some(caller.actor.as_str());
3208            // The ceiling on what one listing may cost, refused rather than
3209            // truncated — see `A2aServer::filter_scan_budget`.
3210            examined += 1;
3211            if examined > server.filter_scan_budget {
3212                return Err(RpcError::new(
3213                    code::INVALID_PARAMS,
3214                    format!(
3215                        "this listing would require examining more than {} tasks to answer \
3216                         exactly — narrow it with statusTimestampAfter and page from there",
3217                        server.filter_scan_budget
3218                    ),
3219                ));
3220            }
3221            // Load-bearing for more than cost: a run the caller may not read
3222            // must not reach the total either, or `totalSize` discloses the
3223            // existence of tasks it cannot see.
3224            if !server.permits(caller, action::TASK_READ, &run.to_string(), owner) {
3225                continue;
3226            }
3227
3228            let wanted = page.len() < page_size;
3229            if !content_filtered && !wanted {
3230                matched += 1;
3231                has_more = true;
3232                continue;
3233            }
3234            let records = server
3235                .runtime
3236                .journal()
3237                .read(run, 1)
3238                .await
3239                .map_err(|e| internal("reading a task's journal", &e))?;
3240            let task = task_from_records(run, &records, updated, params.history_length);
3241            if params
3242                .context_id
3243                .as_ref()
3244                .is_some_and(|context| task.context_id.as_ref() != Some(context))
3245                || params
3246                    .status
3247                    .as_ref()
3248                    .is_some_and(|status| &task.status.state != status)
3249            {
3250                continue;
3251            }
3252            matched += 1;
3253            if wanted {
3254                page.push((run, updated, task));
3255            } else {
3256                has_more = true;
3257            }
3258        }
3259    }
3260
3261    // Exact, and counted over the same rows the caller was allowed to see. The
3262    // easy mistake is reporting the page's length, which tells every caller the
3263    // total is whatever fits on a screen; the dangerous one is counting the
3264    // store's index directly, which is cheaper and reveals the tasks policy
3265    // just hid. Saturated at the wire type's ceiling: the proto field is an
3266    // `int32`, and a count past it must not overflow a conformant client's
3267    // deserializer.
3268    let before = cursor.as_ref().map_or(0, |cursor| cursor.before);
3269    let total_size = before
3270        .saturating_add(matched)
3271        .min(u64::try_from(i32::MAX).unwrap_or(u64::MAX));
3272
3273    let visible = &page[..];
3274    let next_page_token = if has_more {
3275        let (run, updated, _) = visible.last().expect("a page with more has a last item");
3276        encode_task_cursor(&TaskCursor {
3277            updated_at: *updated,
3278            run: run.to_string(),
3279            context_id: params.context_id.clone(),
3280            status: params.status,
3281            status_timestamp_after: params.status_timestamp_after.clone(),
3282            before: before.saturating_add(u64::try_from(visible.len()).unwrap_or(u64::MAX)),
3283        })?
3284    } else {
3285        String::new()
3286    };
3287    let mut tasks = Vec::with_capacity(visible.len());
3288    // The replay budget for this whole request. Cache hits are free; each
3289    // cache miss is a strict replay and spends one. Past it a task's artifacts
3290    // are omitted **and marked** — see [`ARTIFACTS_OMITTED_KEY`] for why the
3291    // omission must say its own name.
3292    let mut replays_left = server.artifact_replay_budget;
3293    for (run, _, task) in visible {
3294        let mut task = task.clone();
3295        if params.include_artifacts {
3296            match server
3297                .artifacts_bounded(*run, task.status.state, Some(&mut replays_left))
3298                .await
3299                .map_err(|error| internal("projecting a task's artifacts", &error))?
3300            {
3301                ArtifactRead::Artifacts(artifacts) => task.artifacts = artifacts,
3302                ArtifactRead::OverBudget => {
3303                    task.metadata = Some(json!({ ARTIFACTS_OMITTED_KEY: true }));
3304                }
3305            }
3306        }
3307        tasks.push(task);
3308    }
3309    Ok(json!({
3310        "tasks": tasks,
3311        "nextPageToken": next_page_token,
3312        "pageSize": page_size,
3313        "totalSize": total_size,
3314    }))
3315}
3316
3317fn encode_task_cursor(cursor: &TaskCursor) -> Result<String, RpcError> {
3318    crate::core::canon::to_bytes(cursor)
3319        .map(crate::core::b64::encode_url)
3320        .map_err(|_| RpcError::new(code::INTERNAL_ERROR, "the task cursor could not be encoded"))
3321}
3322
3323fn decode_task_cursor(token: &str) -> Result<TaskCursor, RpcError> {
3324    crate::core::b64::decode_url(token)
3325        .and_then(|bytes| serde_json::from_slice(&bytes).ok())
3326        .ok_or_else(|| RpcError::new(code::INVALID_PARAMS, "pageToken is not a valid task cursor"))
3327}
3328
3329fn task_from_records(
3330    run: RunId,
3331    records: &[crate::journal::Record],
3332    updated: u64,
3333    history_length: Option<usize>,
3334) -> A2aTask {
3335    // A run with no records is one whose first append has not landed. It is
3336    // reported as working rather than refused, because this builds a row in a
3337    // listing where a single unreadable run must not fail the page.
3338    let (state, detail) =
3339        state_from_history(records).unwrap_or_else(|| (TaskState::Working, "running".to_owned()));
3340    let case = records
3341        .iter()
3342        .find_map(|record| record.body.case.map(|case| case.to_string()));
3343    let timestamp = i64::try_from(updated)
3344        .ok()
3345        .and_then(|seconds| time::OffsetDateTime::from_unix_timestamp(seconds).ok())
3346        .and_then(|value| {
3347            value
3348                .format(&time::format_description::well_known::Rfc3339)
3349                .ok()
3350        });
3351    let history = task_history(run, records, history_length, case.as_deref());
3352    let mut task = task_of(run, state, &detail, case);
3353    task.status.timestamp = timestamp;
3354    task.history = history;
3355    task
3356}
3357
3358/// The most messages an unbounded history request returns.
3359///
3360/// The protocol reads an **unset** `historyLength` as "the full history", so
3361/// the bound is the server's, stated here rather than left to whatever the
3362/// journal happens to hold — the same shape as the artifact replay budget.
3363const HISTORY_CAP: usize = 128;
3364
3365fn task_history(
3366    run: RunId,
3367    records: &[crate::journal::Record],
3368    history_length: Option<usize>,
3369    case: Option<&str>,
3370) -> Option<Vec<A2aMessage>> {
3371    // Unset means full (capped); `0` is the explicit request for none —
3372    // the protocol's own default, which a conformant client expecting its
3373    // conversation back depends on.
3374    let limit = history_length.unwrap_or(HISTORY_CAP);
3375    {
3376        if limit == 0 {
3377            return None;
3378        }
3379        let mut history = Vec::new();
3380        for record in records {
3381            let input = match record.kind() {
3382                RecordKind::RunAdmitted { input, .. } => Some(input),
3383                RecordKind::EffectDone { output, .. } if output.get("$a2a_message").is_some() => {
3384                    Some(output)
3385                }
3386                _ => None,
3387            };
3388            let Some(input) = input else { continue };
3389            if let Some(message) = input.get("$a2a_message")
3390                && let Ok(mut message) = serde_json::from_value::<A2aMessage>(message.clone())
3391            {
3392                message.task_id = Some(run.to_string());
3393                if message.context_id.is_none() {
3394                    message.context_id = case.map(ToOwned::to_owned);
3395                }
3396                history.push(message);
3397                continue;
3398            }
3399            // Non-A2A admission retained for old/directly-created tasks.
3400            if history.is_empty() {
3401                let text = input
3402                    .get("text")
3403                    .and_then(Value::as_str)
3404                    .map(ToOwned::to_owned);
3405                let media_type = if text.is_some() {
3406                    "text/plain"
3407                } else {
3408                    "application/json"
3409                };
3410                history.push(A2aMessage {
3411                    message_id: format!("{run}-input"),
3412                    role: "ROLE_USER".to_owned(),
3413                    parts: vec![Part {
3414                        data: text.is_none().then(|| input.clone()),
3415                        text,
3416                        raw: None,
3417                        url: None,
3418                        filename: None,
3419                        media_type: Some(media_type.to_owned()),
3420                        metadata: None,
3421                    }],
3422                    context_id: case.map(ToOwned::to_owned),
3423                    task_id: Some(run.to_string()),
3424                    metadata: None,
3425                    extensions: Vec::new(),
3426                    reference_task_ids: Vec::new(),
3427                });
3428            }
3429        }
3430        let keep_from = history.len().saturating_sub(limit);
3431        (!history.is_empty()).then(|| history.split_off(keep_from))
3432    }
3433}
3434
3435/// The uncached artifact projection: one strict replay of a completed run.
3436///
3437/// Server request paths go through [`A2aServer::artifacts_bounded`], which
3438/// caches sealed runs and meters `ListTasks`, and a stream through
3439/// [`cached_artifacts`]; this stays the raw read for the push projection.
3440pub(super) async fn task_artifacts(
3441    runtime: &Runtime,
3442    run: RunId,
3443    state: TaskState,
3444) -> Result<Option<Vec<A2aArtifact>>, crate::core::RuntimeError> {
3445    if state != TaskState::Completed {
3446        return Ok(None);
3447    }
3448    runtime
3449        .replay(run, crate::runtime::Mode::Strict)
3450        .await
3451        .map(|outcome| task_of_outcome(&outcome).artifacts)
3452}
3453
3454/// A task's artifacts through the sealed-run cache, unmetered.
3455///
3456/// What a stream reads when the run it watches concludes: every subscriber to
3457/// one task would otherwise buy its own strict replay of the same immutable
3458/// history.
3459pub(super) async fn cached_artifacts(
3460    runtime: &Runtime,
3461    cache: &std::sync::Mutex<ArtifactCache>,
3462    run: RunId,
3463    state: TaskState,
3464) -> Result<Option<Vec<A2aArtifact>>, crate::core::RuntimeError> {
3465    if state != TaskState::Completed {
3466        return Ok(None);
3467    }
3468    if let Some(ArtifactRead::Artifacts(artifacts)) = crate::core::poison::recover(cache).get(run) {
3469        return Ok(artifacts);
3470    }
3471    let artifacts = task_artifacts(runtime, run, state).await?;
3472    crate::core::poison::recover(cache).insert(run, artifacts.clone());
3473    Ok(artifacts)
3474}
3475
3476/// A sealed run's outcome word, as an A2A state.
3477///
3478/// The outcome is the same string [`RunStatus::as_str`](crate::runtime::RunStatus::as_str)
3479/// produces, which is what the executor seals with — so this is [`state_of`]
3480/// reached through a string, and the two are held to agreement by
3481/// `a_live_status_and_its_sealed_outcome_agree`.
3482///
3483/// The `_` arm is a *wire* decision rather than a modelling shortcut: an A2A
3484/// client must be told some state, and a word this build cannot interpret is not
3485/// something it may describe as completed or waiting. It is deliberately **not**
3486/// how the runtime itself treats an unrecognised outcome — `resume_is_closed`
3487/// quarantines on one, because refusing to guess is available there and is not
3488/// available here. The agreement test is what keeps this arm from quietly
3489/// swallowing a variant somebody added and forgot to map.
3490pub(super) fn sealed_state(outcome: &str) -> TaskState {
3491    match outcome {
3492        "succeeded" => TaskState::Completed,
3493        "cancelled" => TaskState::Canceled,
3494        "suspended" => TaskState::InputRequired,
3495        // `RunStatus::Withheld`'s outcome; `a_live_status_and_its_sealed_outcome_agree`
3496        // pins the spelling against the enum.
3497        "withheld" => TaskState::AuthRequired,
3498        // Not tasks at all: nobody submitted this plane's record of a sweep, of
3499        // an operator crossing a tenant boundary or lifting a control, or of a session it merely
3500        // watched somebody else's agent run. `state_of` decides that and this
3501        // agrees, which is the direction the test beside it enforces.
3502        "swept"
3503        | "broke-glass"
3504        | crate::runtime::HALT_LIFTED_OUTCOME
3505        | crate::runtime::HOLD_RELEASED_OUTCOME
3506        | crate::runtime::OBSERVED_OUTCOME => TaskState::Rejected,
3507        _ => TaskState::Failed,
3508    }
3509}
3510
3511/// The A2A state of a run, read from the last record of its history.
3512///
3513/// Every surface that reports a task's state answers here: `tasks/get`,
3514/// `tasks/list`, and the event stream a client subscribes to. They are three
3515/// views of one fact, and three copies of this match would agree until somebody
3516/// added a record kind or reworded a suspension — after which a client polling
3517/// and the same client streaming would be told different things about the same
3518/// run, which is worse than either answer being wrong.
3519///
3520/// **The last record, not any record.** A run that waited, was resumed and
3521/// carried on has a suspension in its history and is not suspended now.
3522///
3523/// An empty history has no state to report and is not this function's to
3524/// invent: the caller decides whether that is a task that does not exist or one
3525/// whose first record has not landed yet, and those get different answers.
3526pub(super) fn state_from_history(
3527    records: &[crate::journal::Record],
3528) -> Option<(TaskState, String)> {
3529    let last = records.last()?;
3530    Some(match last.kind() {
3531        RecordKind::RunSuspended { reason } => (TaskState::InputRequired, reason.to_string()),
3532        RecordKind::RunConcluded { outcome, .. } => (sealed_state(outcome), outcome.clone()),
3533        _ => (TaskState::Working, "running".to_owned()),
3534    })
3535}
3536
3537async fn cancel_task(
3538    server: &A2aServer,
3539    caller: &Caller,
3540    params: CommonParams,
3541) -> Result<Value, RpcError> {
3542    let (id, records) = server
3543        .gate_task(caller, action::TASK_CANCEL, &params)
3544        .await?;
3545    let Some(last) = records.last() else {
3546        return Err(task_not_found(id));
3547    };
3548    // Which runs a stop can reach is the runtime's decision, not this door's:
3549    // a failed, exhausted or withheld run has concluded and is still
3550    // resumable, and stopping it is how its work gets unwound. A closed run
3551    // (succeeded, cancelled, abandoned) and a quarantined one — whose unwind a
3552    // quarantine forbids — are refused with A2A's code for exactly this.
3553    let fresh = server
3554        .runtime
3555        .request_cancel(
3556            id,
3557            &crate::core::Operator::authenticated(caller.actor.clone())
3558                .map_err(|e| internal("naming the authenticated caller", &e))?,
3559            "cancelled over A2A",
3560        )
3561        .await
3562        .map_err(|e| match e {
3563            crate::core::RuntimeError::AlreadyConcluded { outcome, .. } => RpcError::new(
3564                code::TASK_NOT_CANCELABLE,
3565                format!("this task already finished as '{outcome}'"),
3566            ),
3567            crate::core::RuntimeError::CannotUnwind { .. } => RpcError::new(
3568                code::TASK_NOT_CANCELABLE,
3569                "this task is quarantined; its work cannot be unwound until the \
3570                 deployment resolves it",
3571            ),
3572            other => internal("requesting a cancellation", &other),
3573        })?;
3574    // Somebody stopped it already and it has finished stopping: telling this
3575    // caller a cancellation is under way would describe a stop that is over.
3576    // (The runtime answers a closed run `false` rather than an error only when
3577    // the run was cancelled.)
3578    if let RecordKind::RunConcluded { outcome, .. } = last.kind()
3579        && !fresh
3580        && outcome == "cancelled"
3581    {
3582        return Err(RpcError::new(
3583            code::TASK_NOT_CANCELABLE,
3584            format!("this task already finished as '{outcome}'"),
3585        ));
3586    }
3587
3588    // The same resolution every other path uses, from the records already in
3589    // hand: A2A 1.0 puts `contextId` on every task, and the one place that
3590    // hard-coded it absent handed a canceling caller a task it could not
3591    // continue or correlate.
3592    let case = records
3593        .iter()
3594        .find_map(|record| record.body.case.map(|case| case.to_string()));
3595
3596    // Still `WORKING`, deliberately. The request is durable, and the run stops
3597    // at its next step boundary — reporting `CANCELED` here would claim it had
3598    // already stopped and unwound, which is exactly what has not happened yet.
3599    serde_json::to_value(task_of(
3600        id,
3601        TaskState::Working,
3602        "cancellation requested",
3603        case,
3604    ))
3605    .map_err(|error| internal("encoding a task", &error))
3606}
3607
3608fn get_extended_card(server: &A2aServer, caller: &Caller) -> Result<Value, RpcError> {
3609    server.authorize(caller, action::CARD_EXTENDED, &server.card.name, None)?;
3610    serde_json::to_value(&server.extended).map_err(|e| internal("encoding the extended card", &e))
3611}
3612
3613fn task_id(params: &CommonParams) -> Result<RunId, RpcError> {
3614    let Some(raw) = params.id.as_deref() else {
3615        return Err(RpcError::new(code::INVALID_PARAMS, "`id` is required"));
3616    };
3617    RunId::parse(raw).map_err(|_| {
3618        // Not found rather than invalid params: whether a string is a run id
3619        // this plane issued is not something a caller should learn from the
3620        // shape of the refusal.
3621        RpcError::new(code::TASK_NOT_FOUND, format!("no such task: {raw}"))
3622    })
3623}
3624
3625/// The agent declining, with nothing about *why*.
3626///
3627/// The reason is deliberately absent. A denial reason describes the rule that
3628/// fired, and a rule describes the classification it protects — so a caller who
3629/// can send messages and read refusals can map the policy by probing it. The
3630/// operator sees the reason in the journal, where it belongs; the peer sees that
3631/// it was declined, which is all it can act on anyway.
3632fn declined(skill: &str) -> A2aMessage {
3633    A2aMessage {
3634        message_id: format!("declined-{skill}"),
3635        role: "ROLE_AGENT".to_owned(),
3636        parts: vec![Part {
3637            text: Some("this agent declined the request".to_owned()),
3638            data: None,
3639            raw: None,
3640            url: None,
3641            filename: None,
3642            media_type: Some("text/plain".to_owned()),
3643            metadata: None,
3644        }],
3645        context_id: None,
3646        task_id: None,
3647        metadata: None,
3648        extensions: Vec::new(),
3649        reference_task_ids: Vec::new(),
3650    }
3651}
3652
3653pub(super) fn task_of(run: RunId, state: TaskState, detail: &str, case: Option<String>) -> A2aTask {
3654    A2aTask {
3655        id: run.to_string(),
3656        context_id: case,
3657        status: TaskStatus {
3658            state,
3659            message: Some(A2aMessage {
3660                message_id: format!("{run}-status"),
3661                role: "ROLE_AGENT".to_owned(),
3662                parts: vec![Part {
3663                    text: Some(detail.to_owned()),
3664                    data: None,
3665                    raw: None,
3666                    url: None,
3667                    filename: None,
3668                    media_type: Some("text/plain".to_owned()),
3669                    metadata: None,
3670                }],
3671                context_id: None,
3672                task_id: Some(run.to_string()),
3673                metadata: None,
3674                extensions: Vec::new(),
3675                reference_task_ids: Vec::new(),
3676            }),
3677            timestamp: None,
3678        },
3679        artifacts: None,
3680        history: None,
3681        metadata: None,
3682    }
3683}
3684
3685fn push_runtime(server: &A2aServer) -> Result<&PushRuntime, RpcError> {
3686    server.push.as_ref().ok_or_else(push_not_supported_error)
3687}
3688
3689fn push_not_supported_error() -> RpcError {
3690    RpcError::new(
3691        code::PUSH_NOT_SUPPORTED,
3692        "this agent does not implement push notifications; its card advertises pushNotifications as false",
3693    )
3694}
3695
3696/// The task a push method addresses, refused unless this caller admitted it.
3697///
3698/// Owned before anything is read or written: a webhook registration is a
3699/// standing instruction to send a task's history somewhere, so one peer
3700/// registering on — or reading, listing, deleting the registrations of —
3701/// another's task would be a read of that task with a delivery address
3702/// attached.
3703async fn push_task<'a>(
3704    server: &'a A2aServer,
3705    caller: &Caller,
3706    raw: Option<&str>,
3707) -> Result<(RunId, &'a PushRuntime), RpcError> {
3708    let push = push_runtime(server)?;
3709    let raw = raw.ok_or_else(|| RpcError::new(code::INVALID_PARAMS, "`taskId` is required"))?;
3710    let task = RunId::parse(raw)
3711        .map_err(|_| RpcError::new(code::TASK_NOT_FOUND, format!("no such task: {raw}")))?;
3712    server
3713        .authorized_task(caller, action::TASK_PUSH, task)
3714        .await?;
3715    Ok((task, push))
3716}
3717
3718fn push_request(params: &CommonParams) -> Result<PushRequest, RpcError> {
3719    let url = params
3720        .url
3721        .clone()
3722        .ok_or_else(|| RpcError::new(code::INVALID_PARAMS, "`url` is required"))?;
3723    Ok(PushRequest {
3724        id: params.id.clone(),
3725        task_id: params.push_task.clone(),
3726        url,
3727        token: params.token.clone(),
3728        authentication: params.authentication.clone(),
3729    })
3730}
3731
3732fn validate_push_request(server: &A2aServer, request: &PushRequest) -> Result<(), RpcError> {
3733    let push = push_runtime(server)?;
3734    request.validate()?;
3735    let config = request.config(RunId::generate());
3736    if let Some(authentication) = &config.authentication {
3737        authentication
3738            .validate()
3739            .map_err(|error| RpcError::new(code::INVALID_PARAMS, error.to_string()))?;
3740    }
3741    push.transport
3742        .validate(&config)
3743        .map_err(|error| RpcError::new(code::INVALID_PARAMS, error.to_string()))
3744}
3745
3746fn validate_inline_push(
3747    server: &A2aServer,
3748    caller: &Caller,
3749    skill: &str,
3750    request: &PushRequest,
3751) -> Result<(), RpcError> {
3752    if request
3753        .task_id
3754        .as_deref()
3755        .is_some_and(|task| !task.is_empty())
3756    {
3757        return Err(RpcError::new(
3758            code::INVALID_PARAMS,
3759            "taskPushNotificationConfig.taskId must be empty in SendMessage",
3760        ));
3761    }
3762    server.authorize(caller, action::TASK_PUSH, &format!("new:{skill}"), None)?;
3763    validate_push_request(server, request)
3764}
3765
3766async fn register_push(
3767    server: &A2aServer,
3768    request: &PushRequest,
3769    task: RunId,
3770    next_seq: Seq,
3771) -> Result<crate::push::PushConfig, RpcError> {
3772    let push = push_runtime(server)?;
3773    if request
3774        .task_id
3775        .as_deref()
3776        .is_some_and(|configured| !configured.is_empty() && configured != task.to_string())
3777    {
3778        return Err(RpcError::new(
3779            code::INVALID_PARAMS,
3780            "push configuration taskId does not match its task",
3781        ));
3782    }
3783    request.validate()?;
3784    let config = request.config(task);
3785    if let Some(authentication) = &config.authentication {
3786        authentication
3787            .validate()
3788            .map_err(|error| RpcError::new(code::INVALID_PARAMS, error.to_string()))?;
3789    }
3790    push.transport
3791        .validate(&config)
3792        .map_err(|error| RpcError::new(code::INVALID_PARAMS, error.to_string()))?;
3793    // The deployment's own destinations are not the caller's to spend.
3794    let held: Vec<String> = push
3795        .store
3796        .list(task)
3797        .await
3798        .map_err(|error| internal("listing push configurations", &error))?
3799        .into_iter()
3800        .map(|c| c.id)
3801        .filter(|id| !crate::push::is_operator_id(id))
3802        .collect();
3803    if held.len() >= MAX_PUSH_CONFIGS_PER_TASK && !held.contains(&config.id) {
3804        return Err(RpcError::new(
3805            code::INVALID_PARAMS,
3806            format!(
3807                "a task holds at most {MAX_PUSH_CONFIGS_PER_TASK} push configurations; \
3808                 delete one first"
3809            ),
3810        ));
3811    }
3812    push.store
3813        .put(&config, next_seq)
3814        .await
3815        .map_err(|error| internal("storing a push configuration", &error))?;
3816    Ok(config)
3817}
3818
3819async fn push_create(
3820    server: &A2aServer,
3821    caller: &Caller,
3822    params: &CommonParams,
3823) -> Result<Value, RpcError> {
3824    let (task, _) = push_task(server, caller, params.push_task.as_deref()).await?;
3825    let request = push_request(params)?;
3826    let head = server
3827        .runtime
3828        .journal()
3829        .head(task)
3830        .await
3831        .map_err(|error| internal("reading a task's head", &error))?;
3832    let tail = server
3833        .runtime
3834        .journal()
3835        .read(task, head.seq)
3836        .await
3837        .map_err(|error| internal("reading a task's journal", &error))?;
3838    let next_seq = if tail
3839        .last()
3840        .is_some_and(|record| matches!(record.kind(), RecordKind::RunConcluded { .. }))
3841    {
3842        head.seq
3843    } else {
3844        head.seq.saturating_add(1)
3845    };
3846    Ok(register_push(server, &request, task, next_seq)
3847        .await?
3848        .redacted())
3849}
3850
3851async fn push_get(
3852    server: &A2aServer,
3853    caller: &Caller,
3854    params: &CommonParams,
3855) -> Result<Value, RpcError> {
3856    let (task, push) = push_task(server, caller, params.push_task.as_deref()).await?;
3857    let (id, reachable) = caller_push_id(params)?;
3858    let missing = || {
3859        RpcError::new(
3860            code::TASK_NOT_FOUND,
3861            format!("no push configuration '{id}' for task {task}"),
3862        )
3863    };
3864    if !reachable {
3865        return Err(missing());
3866    }
3867    push.store
3868        .get(task, id)
3869        .await
3870        .map_err(|error| internal("reading a push configuration", &error))?
3871        .map(|config| config.redacted())
3872        .ok_or_else(missing)
3873}
3874
3875/// The `id` a push method names, and whether it lies in the caller's half of
3876/// the store — the only half a push method may reach.
3877///
3878/// A caller's webhooks and the deployment's own destinations share one
3879/// [`PushStore`](crate::push::PushStore), told apart by
3880/// [`OPERATOR_PREFIX`](crate::push::OPERATOR_PREFIX), and the outbox registers
3881/// an operator destination against every run. The A2A methods answer for the
3882/// caller's namespace and nothing else: an operator id is answered exactly as
3883/// a configuration that does not exist, so a peer can neither read the
3884/// deployment's destination URL nor delete it (and its delivery cursor) from
3885/// under the operator.
3886fn caller_push_id(params: &CommonParams) -> Result<(&str, bool), RpcError> {
3887    let id = params
3888        .id
3889        .as_deref()
3890        .ok_or_else(|| RpcError::new(code::INVALID_PARAMS, "`id` is required"))?;
3891    Ok((id, PushNamespace::Caller.owns_id(id)))
3892}
3893
3894async fn push_list(
3895    server: &A2aServer,
3896    caller: &Caller,
3897    params: &CommonParams,
3898) -> Result<Value, RpcError> {
3899    let (task, push) = push_task(server, caller, params.push_task.as_deref()).await?;
3900    let configs = push
3901        .store
3902        .list(task)
3903        .await
3904        .map_err(|error| internal("listing push configurations", &error))?;
3905    Ok(json!({
3906        "configs": configs
3907            .iter()
3908            .filter(|config| PushNamespace::Caller.owns_id(&config.id))
3909            .map(crate::push::PushConfig::redacted)
3910            .collect::<Vec<_>>(),
3911        "nextPageToken": "",
3912    }))
3913}
3914
3915async fn push_delete(
3916    server: &A2aServer,
3917    caller: &Caller,
3918    params: &CommonParams,
3919) -> Result<Value, RpcError> {
3920    let (task, push) = push_task(server, caller, params.push_task.as_deref()).await?;
3921    let (id, reachable) = caller_push_id(params)?;
3922    if !reachable {
3923        // The same answer a delete of a missing configuration gets.
3924        return Ok(json!({}));
3925    }
3926    push.store
3927        .delete(task, id)
3928        .await
3929        .map_err(|error| internal("deleting a push configuration", &error))?;
3930    Ok(json!({}))
3931}
3932
3933#[cfg(test)]
3934mod state_agreement_tests {
3935    use super::{TaskState, sealed_state, state_of};
3936
3937    /// The same task must not have two states depending on which path a client took.
3938    ///
3939    /// `state_of` answers the caller holding the immediate `SendMessage`
3940    /// response; `sealed_state` answers `GetTask`, `SubscribeToTask` and every
3941    /// streamed status update. They read the same run.
3942    ///
3943    /// The check runs over the crate's one `RunStatus` list, so adding a variant
3944    /// fails to compile in `state_of` (the match is exhaustive) and, once
3945    /// somebody maps it there, fails *here* until `sealed_state` is taught the
3946    /// same answer. That is the ordering the defect needs: the enum decides, the
3947    /// string agrees. Unifying them the other way — `state_of` delegating to
3948    /// `sealed_state` — makes both paths return the `_ => Failed` fallback for a
3949    /// new variant, with nothing to notice, and that is what this test exists to
3950    /// stop being reintroduced as a tidy-up.
3951    #[test]
3952    fn a_live_status_and_its_sealed_outcome_agree() {
3953        let statuses = crate::runtime::every_status();
3954        assert_eq!(
3955            statuses.len(),
3956            14,
3957            "a RunStatus variant was added or removed — decide which A2A state it \
3958             surfaces as, in `state_of` and in `sealed_state` both"
3959        );
3960        for status in &statuses {
3961            assert_eq!(
3962                state_of(status),
3963                sealed_state(status.as_str()),
3964                "'{}' surfaces as one state live and another once sealed",
3965                status.as_str()
3966            );
3967        }
3968    }
3969
3970    /// And the mapping is not one answer for everything.
3971    ///
3972    /// Without this, `sealed_state` and `state_of` could both be changed to
3973    /// return `Failed` unconditionally and the agreement test above would pass
3974    /// perfectly — every A2A client would see every task as failed.
3975    #[test]
3976    fn the_mapping_distinguishes_more_than_failure() {
3977        let seen: std::collections::BTreeSet<_> = crate::runtime::every_status()
3978            .iter()
3979            .map(|status| format!("{:?}", state_of(status)))
3980            .collect();
3981        assert!(
3982            seen.len() >= 4,
3983            "the run-status mapping collapsed to {seen:?}; a client cannot tell \
3984             completed from cancelled from waiting"
3985        );
3986        assert_eq!(
3987            state_of(&crate::runtime::RunStatus::Succeeded),
3988            TaskState::Completed
3989        );
3990    }
3991
3992    /// A withheld run is interrupted, not finished.
3993    ///
3994    /// Its work stands and it continues once its authority is restored. A
3995    /// terminal answer would close the peer's stream and release its push
3996    /// registration, so the peer would never hear how the task ended.
3997    #[test]
3998    fn a_withheld_task_is_interrupted_not_terminal() {
3999        let withheld = crate::runtime::every_status()
4000            .into_iter()
4001            .find(|status| matches!(status, crate::runtime::RunStatus::Withheld { .. }))
4002            .expect("every_status lists Withheld");
4003        assert_eq!(state_of(&withheld), TaskState::AuthRequired);
4004        assert!(
4005            !super::outcome_ends_task(withheld.as_str()),
4006            "a withheld conclusion released the peer's push registration"
4007        );
4008        assert!(super::outcome_ends_task("succeeded"));
4009        assert!(super::outcome_ends_task("failed"));
4010    }
4011}