Skip to main content

a2a_rs/adapter/storage/
sqlx_storage.rs

1//! SQLx-based task storage implementation.
2//!
3//! Persists tasks, history, push configs and conversations to SQLite or
4//! PostgreSQL. The backend is chosen from the URL scheme at runtime, over sqlx's
5//! `Any` driver, so both are served by one set of queries; the differences live
6//! in [`Dialect`](super::dialect::Dialect).
7
8#[cfg(feature = "sqlx-storage")]
9use async_trait::async_trait;
10#[cfg(feature = "sqlx-storage")]
11use serde_json;
12#[cfg(feature = "sqlx-storage")]
13use sqlx::{
14    AnyPool, ConnectOptions, Row,
15    any::{AnyConnectOptions, AnyPoolOptions},
16};
17#[cfg(feature = "sqlx-storage")]
18use std::{
19    collections::HashMap,
20    str::FromStr,
21    time::{Duration, Instant},
22};
23
24#[cfg(feature = "sqlx-storage")]
25use crate::adapter::business::push_notification::{
26    PushNotificationRegistry, PushNotificationSender,
27};
28
29#[cfg(feature = "sqlx-storage")]
30#[cfg(feature = "http-client")]
31use crate::adapter::business::push_notification::HttpPushNotificationSender;
32#[cfg(feature = "sqlx-storage")]
33#[cfg(not(feature = "http-client"))]
34use crate::adapter::business::push_notification::NoopPushNotificationSender;
35
36#[cfg(feature = "sqlx-storage")]
37use crate::domain::{
38    A2AError, ContextId, ContextState, Conversation, Digest, Message, ReadRefresh, Remembered,
39    RetentionPolicy, Seq, SequencedMessage, StateKey, StateScope, Swept, Task, TaskId,
40    TaskPushNotificationConfig, TaskState, TaskStateExt, TaskStatus, VersionedTask,
41};
42#[cfg(feature = "sqlx-storage")]
43use crate::port::{
44    AsyncContextStateStore, AsyncConversationStore, AsyncEventLog, AsyncNotificationManager,
45    AsyncPushNotifier, AsyncRetention, AsyncTaskLifecycle, AsyncTaskQuery, AsyncTaskVersioning,
46    Replay, SeqEvent, UpdateEvent, context_state::scope_key,
47};
48
49#[cfg(feature = "sqlx-storage")]
50use std::sync::Arc;
51
52#[cfg(feature = "sqlx-storage")]
53/// SQLx-based task storage for persistent storage.
54///
55/// Persistence-only: streaming fan-out lives in
56/// [`InMemoryStreamingHandler`](crate::adapter::InMemoryStreamingHandler) and
57/// push-webhook delivery behind the [`AsyncPushNotifier`] port (handed out via
58/// [`push_notifier`](Self::push_notifier)). The store still owns push-config
59/// CRUD ([`AsyncNotificationManager`]) — that is config persistence.
60pub struct SqlxTaskStorage {
61    /// Database pool, over the driver the URL scheme selected.
62    pool: AnyPool,
63    /// Which SQL the queries are rendered in. Fixed at connect time from the
64    /// same URL the pool was opened with.
65    dialect: Dialect,
66    /// Push notification registry (config store + delivery backend)
67    push_notification_registry: Arc<PushNotificationRegistry>,
68    /// How many events per task the stream log keeps; `None` keeps all of them.
69    event_log_capacity: Option<u64>,
70    /// Whether a read of a principal's `user:` bag counts as keeping it alive.
71    /// [`ReadRefresh::never`] by default, matching the in-memory store.
72    read_refresh: ReadRefresh,
73    /// The settled ownership answers this store has already read back, if it
74    /// was given a window to remember them for. See [`ClaimCache`].
75    ///
76    /// Behind an `Arc` so a clone shares it. Two clones with a cache each would
77    /// each hold entries the other's sweep evicted, which is the one way this
78    /// could hold an answer the row no longer agrees with for longer than the
79    /// TTL.
80    claim_cache: Option<Arc<ClaimCache>>,
81}
82
83#[cfg(feature = "sqlx-storage")]
84use super::database_config::DatabaseType;
85#[cfg(feature = "sqlx-storage")]
86use super::dialect::Dialect;
87
88/// The task columns this store reads, spelled out rather than `SELECT *`.
89///
90/// Both tables carry timestamps the `Any` driver cannot decode — it handles
91/// text, integers, floats, booleans and bytes — and a row is converted whole, so
92/// selecting a column nobody reads fails the query on PostgreSQL.
93#[cfg(feature = "sqlx-storage")]
94/// The `status_state` spelling of a task state.
95///
96/// One copy. It was written out at five sites — three that update a task, one
97/// that appends history, and the list filter — against a sixth match in
98/// `row_to_task` that reads it back, and anything the writer spells differently
99/// from the reader comes back as `TaskState::Unknown`.
100#[cfg(feature = "sqlx-storage")]
101fn state_str(state: TaskState) -> &'static str {
102    match state {
103        TaskState::Submitted => "submitted",
104        TaskState::Working => "working",
105        TaskState::InputRequired => "input-required",
106        TaskState::Completed => "completed",
107        TaskState::Canceled => "canceled",
108        TaskState::Failed => "failed",
109        TaskState::Rejected => "rejected",
110        TaskState::AuthRequired => "auth-required",
111        TaskState::Unknown => "unknown",
112    }
113}
114
115/// The `status_message` column value for a status carrying `message`.
116///
117/// `None` is an answer rather than an absence to skip: the message belongs to
118/// the *current* status, so a transition carrying none has to clear what the
119/// last one left. `Task::update_status` replaces the whole `TaskStatus`, and
120/// this is the storage-side half of that.
121#[cfg(feature = "sqlx-storage")]
122fn status_message_json(message: Option<&Message>) -> Option<String> {
123    message.map(|m| serde_json::to_string(m).unwrap_or_default())
124}
125
126const TASK_COLUMNS: &str = "id, context_id, status_state, status_message, metadata, artifacts";
127
128/// How many events per task [`SqlxTaskStorage`] keeps for stream resumption.
129///
130/// Four times what the in-memory log holds: rows are cheaper than process
131/// memory, and the reason to persist the log at all is to cover a disconnection
132/// long enough that the process may not be the same one afterwards.
133#[cfg(feature = "sqlx-storage")]
134pub const DEFAULT_EVENT_LOG_CAPACITY: u64 = 1024;
135
136/// How long [`SqlxTaskStorage`] remembers a context's settled owner.
137///
138/// Seconds, not the process lifetime, and that is the whole design: the memo
139/// exists to collapse the several ownership questions *one turn* asks, not to
140/// stop asking across turns. See [`SqlxStorageBuilder::claim_cache`].
141#[cfg(feature = "sqlx-storage")]
142pub const DEFAULT_CLAIM_CACHE_TTL: Duration = Duration::from_secs(5);
143
144/// How many contexts a [`ClaimCache`] holds before it starts over.
145#[cfg(feature = "sqlx-storage")]
146const CLAIM_CACHE_CAPACITY: usize = 1024;
147
148/// The settled ownership answers this process has already read back.
149///
150/// A cache of an authorization input needs an argument, and this is it: the
151/// value cached cannot change. `contexts.owner` is written once, by the claim,
152/// and never reassigned — `Open` stays open and `Owner(x)` stays `x`. So a hit
153/// is not a second opinion about who owns a context, it is the same answer read
154/// less often.
155///
156/// What *can* happen to a row is deletion, by a retention sweep, after which a
157/// different principal may claim the id afresh. Three things bound that:
158///
159/// - Only settled answers go in. The absent row — the one case a caller has to
160///   act on by writing — is never cached, so the claiming path is unchanged.
161/// - This store evicts what it deletes, so a sweep it runs itself cannot leave
162///   a stale entry behind.
163/// - A sweep run by *another* replica cannot, which is what the TTL is for. The
164///   window it leaves needs that replica to sweep a context idle for days, a
165///   different principal to re-claim the same id, and this replica to serve a
166///   request against it — all within a few seconds.
167///
168/// Storing the claim rather than the verdict is what keeps one entry from
169/// admitting the wrong caller: [`ContextClaim::verdict`] still runs per request.
170#[cfg(feature = "sqlx-storage")]
171struct ClaimCache {
172    /// Cleared whole at [`CLAIM_CACHE_CAPACITY`] rather than evicted one at a
173    /// time. A miss costs one `SELECT`, and for a memo measured in seconds that
174    /// is not worth an LRU — or the dependency one would come with.
175    entries: std::sync::Mutex<HashMap<String, (ContextClaim, Instant)>>,
176    ttl: Duration,
177}
178
179#[cfg(feature = "sqlx-storage")]
180impl ClaimCache {
181    fn new(ttl: Duration) -> Self {
182        Self {
183            entries: std::sync::Mutex::new(HashMap::new()),
184            ttl,
185        }
186    }
187
188    /// What this process last read for `context_id`, if that was recent enough.
189    ///
190    /// A poisoned lock reads as a miss: the fallback is the `SELECT` this exists
191    /// to skip, and failing a request over a cache is the wrong trade.
192    fn get(&self, context_id: &str) -> Option<ContextClaim> {
193        let entries = self.entries.lock().ok()?;
194        let (claim, at) = entries.get(context_id)?;
195        (at.elapsed() < self.ttl).then(|| claim.clone())
196    }
197
198    fn insert(&self, context_id: &str, claim: &ContextClaim) {
199        let Ok(mut entries) = self.entries.lock() else {
200            return;
201        };
202        if entries.len() >= CLAIM_CACHE_CAPACITY {
203            entries.clear();
204        }
205        entries.insert(context_id.to_string(), (claim.clone(), Instant::now()));
206    }
207
208    fn forget(&self, context_id: &str) {
209        if let Ok(mut entries) = self.entries.lock() {
210            entries.remove(context_id);
211        }
212    }
213}
214
215/// How the pool is opened. Applied to both the main pool and the
216/// one-connection migration pool, which connect to the same database.
217#[cfg(feature = "sqlx-storage")]
218struct PoolSettings {
219    max_connections: u32,
220    acquire_timeout: Duration,
221    log_statements: bool,
222}
223
224#[cfg(feature = "sqlx-storage")]
225impl Default for PoolSettings {
226    /// sqlx's own pool defaults, so an unconfigured store is sized as it was
227    /// before there was anything to configure. Statement logging is the one
228    /// departure: sqlx logs every statement at `DEBUG` by default, and this
229    /// crate makes that a choice (see [`SqlxStorageBuilder::log_statements`]).
230    fn default() -> Self {
231        Self {
232            max_connections: 10,
233            acquire_timeout: Duration::from_secs(30),
234            log_statements: false,
235        }
236    }
237}
238
239#[cfg(feature = "sqlx-storage")]
240impl PoolSettings {
241    fn connect_options(&self, url: &str) -> Result<AnyConnectOptions, A2AError> {
242        let options = AnyConnectOptions::from_str(url)
243            .map_err(|e| A2AError::DatabaseError(format!("Invalid database URL '{url}': {e}")))?;
244
245        Ok(if self.log_statements {
246            options
247        } else {
248            options.disable_statement_logging()
249        })
250    }
251}
252
253/// Builds a [`SqlxTaskStorage`]: see [`SqlxTaskStorage::builder`].
254#[cfg(feature = "sqlx-storage")]
255pub struct SqlxStorageBuilder {
256    url: String,
257    pool: PoolSettings,
258    push_sender: Option<Arc<dyn PushNotificationSender>>,
259    additional_migrations: Vec<String>,
260    event_log_capacity: Option<u64>,
261    read_refresh: ReadRefresh,
262    claim_cache_ttl: Option<Duration>,
263}
264
265#[cfg(feature = "sqlx-storage")]
266impl SqlxStorageBuilder {
267    /// Take the URL and the pool settings from a [`DatabaseConfig`].
268    pub fn from_config(config: &super::database_config::DatabaseConfig) -> Self {
269        SqlxTaskStorage::builder(&config.url)
270            .max_connections(config.max_connections)
271            .acquire_timeout(Duration::from_secs(config.timeout_seconds))
272            .log_statements(config.enable_logging)
273    }
274
275    /// Cap the connection pool. Defaults to 10, sqlx's own default.
276    ///
277    /// On PostgreSQL this is a share of a server-wide limit, so a fleet of
278    /// agents against one server is the case worth setting it for.
279    pub fn max_connections(mut self, max: u32) -> Self {
280        self.pool.max_connections = max;
281        self
282    }
283
284    /// How long a query waits for a free connection before failing. Defaults
285    /// to 30 seconds, sqlx's own default.
286    pub fn acquire_timeout(mut self, timeout: Duration) -> Self {
287        self.pool.acquire_timeout = timeout;
288        self
289    }
290
291    /// Log every statement the store executes, at `DEBUG` through the `log`
292    /// crate (which `tracing-subscriber` bridges into tracing). Off by default.
293    pub fn log_statements(mut self, log: bool) -> Self {
294        self.pool.log_statements = log;
295        self
296    }
297
298    /// Deliver push notifications through this sender rather than the default
299    /// (HTTP with the `http-client` feature, a no-op without it).
300    pub fn push_sender(mut self, sender: impl PushNotificationSender + 'static) -> Self {
301        self.push_sender = Some(Arc::new(sender));
302        self
303    }
304
305    /// How many of a task's stream events to keep for resumption, past which
306    /// the oldest are dropped as new ones arrive. Defaults to
307    /// [`DEFAULT_EVENT_LOG_CAPACITY`].
308    ///
309    /// The cap is what bounds one task; a retention sweep is what bounds their
310    /// number. A client disconnected for longer than this many events is told
311    /// the log cannot cover it and resumes from the task snapshot instead, so
312    /// raising it buys longer disconnections and costs rows.
313    ///
314    /// `None` keeps every event, which leaves the table growing with nothing but
315    /// the sweep to reclaim it.
316    pub fn event_log_capacity(mut self, capacity: Option<u64>) -> Self {
317        self.event_log_capacity = capacity.map(|capacity| capacity.max(1));
318        self
319    }
320
321    /// How long to remember a context's settled owner, past which it is read
322    /// again. Defaults to [`DEFAULT_CLAIM_CACHE_TTL`]; `None` reads it every
323    /// time.
324    ///
325    /// Ownership is checked on every conversation read, every state-bag read
326    /// and every `remember`, so a turn asks it several times and gets the same
327    /// answer — one that, once the context's row exists, can never change. This
328    /// is what stops that costing a statement each.
329    ///
330    /// What the window buys back is a sweep run by *another* process. This
331    /// store evicts what its own sweep deletes, but a second replica deleting a
332    /// context that is then re-claimed by a different principal leaves this one
333    /// admitting the old answer until the entry expires. Set `None` where that
334    /// matters more than the statements — an audited store, or one whose
335    /// contexts are swept aggressively enough to be recycled.
336    pub fn claim_cache(mut self, ttl: Option<Duration>) -> Self {
337        self.claim_cache_ttl = ttl;
338        self
339    }
340
341    /// Let a read of a principal's `user:` bag count as keeping it alive.
342    ///
343    /// Off by default. See [`ReadRefresh`] for what it costs — one conditional
344    /// `UPDATE` per `load_state`, writing at most once per principal per window
345    /// — and [`ReadRefresh::halfway_through`] for wiring it from the
346    /// [`RetentionPolicy`] a sweep will run under.
347    pub fn read_refresh(mut self, read_refresh: ReadRefresh) -> Self {
348        self.read_refresh = read_refresh;
349        self
350    }
351
352    /// Run these statements after the framework's own migrations.
353    ///
354    /// The caller's own SQL, run verbatim, so it has to be written in the
355    /// dialect the URL selects.
356    pub fn migrations<S: AsRef<str>>(mut self, migrations: impl IntoIterator<Item = S>) -> Self {
357        self.additional_migrations
358            .extend(migrations.into_iter().map(|s| s.as_ref().to_string()));
359        self
360    }
361
362    /// Open the pool, migrate, and hand back the store.
363    pub async fn connect(self) -> Result<SqlxTaskStorage, A2AError> {
364        if self.pool.max_connections == 0 {
365            return Err(A2AError::DatabaseError(
366                "max_connections must be greater than 0; a pool that hands out no connections \
367                 fails every query"
368                    .to_string(),
369            ));
370        }
371
372        let (pool, dialect) = SqlxTaskStorage::connect(&self.url, &self.pool).await?;
373        SqlxTaskStorage::run_additional_migrations(&pool, &self.additional_migrations).await?;
374
375        let push_registry = match self.push_sender {
376            Some(sender) => PushNotificationRegistry::from_shared(sender),
377            None => {
378                #[cfg(feature = "http-client")]
379                let sender = HttpPushNotificationSender::new();
380                #[cfg(not(feature = "http-client"))]
381                let sender = NoopPushNotificationSender::default();
382                PushNotificationRegistry::new(sender)
383            }
384        };
385
386        Ok(SqlxTaskStorage {
387            pool,
388            dialect,
389            push_notification_registry: Arc::new(push_registry),
390            event_log_capacity: self.event_log_capacity,
391            read_refresh: self.read_refresh,
392            claim_cache: self
393                .claim_cache_ttl
394                .map(|ttl| Arc::new(ClaimCache::new(ttl))),
395        })
396    }
397}
398
399/// What the `contexts` row says about who may read a conversation.
400///
401/// The absence of a row is a third answer and is spelled `Option<ContextClaim>`
402/// rather than a variant here: it is the one case the caller has to *act* on by
403/// writing, and folding it in would let a call site treat "nothing holds this
404/// yet" as a decision that had been made.
405#[cfg(feature = "sqlx-storage")]
406#[derive(Clone)]
407enum ContextClaim {
408    /// A row with no owner — an agent running without an authenticator. Open to
409    /// anyone.
410    Open,
411    /// Claimed by this principal on the first write, and never reassigned.
412    Owner(String),
413}
414
415#[cfg(feature = "sqlx-storage")]
416impl ContextClaim {
417    fn verdict(&self, context_id: &str, caller: Option<&str>) -> Result<(), A2AError> {
418        match self {
419            Self::Open => Ok(()),
420            Self::Owner(owner) if Some(owner.as_str()) == caller => Ok(()),
421            Self::Owner(_) => Err(A2AError::ContextAccessDenied {
422                context_id: context_id.to_string(),
423            }),
424        }
425    }
426}
427
428#[cfg(feature = "sqlx-storage")]
429impl SqlxTaskStorage {
430    /// Resolve the URL to a dialect, or say why it cannot be.
431    ///
432    /// Three ways this fails, and they need different answers: an unrecognized
433    /// scheme, a recognized one with no adapter behind it (MySQL), and a
434    /// recognized one whose driver was not compiled in.
435    fn dialect_for(database_url: &str) -> Result<Dialect, A2AError> {
436        let Some(database_type) = DatabaseType::from_url(database_url) else {
437            return Err(A2AError::DatabaseError(format!(
438                "Unrecognized database URL scheme in '{database_url}'. Expected sqlite: or \
439                 postgres:, e.g. 'sqlite::memory:' or 'postgres://user:pass@localhost/a2a'"
440            )));
441        };
442
443        let Some(dialect) = Dialect::of(database_type) else {
444            return Err(A2AError::DatabaseError(format!(
445                "{database_type} is not supported by SqlxTaskStorage. It stores tasks in SQLite \
446                 or PostgreSQL; there is no {database_type} schema."
447            )));
448        };
449
450        if !database_type.is_feature_enabled() {
451            return Err(A2AError::DatabaseError(format!(
452                "{database_type} detected from URL '{database_url}', but the '{}' feature is not \
453                 enabled. Add `features = [\"{}\"]` to your a2a-rs dependency.",
454                database_type.feature_name(),
455                database_type.feature_name(),
456            )));
457        }
458
459        Ok(dialect)
460    }
461
462    /// Give an in-memory SQLite URL a name the whole pool can share.
463    ///
464    /// `sqlite::memory:` is an *anonymous* database, and sqlx names one by
465    /// inventing `sqlx-in-memory-{n}` while parsing the URL. A typed
466    /// `SqlitePool` parses once and every connection lands in the same one; the
467    /// `Any` driver parses per connection, so a pool of ten would be ten empty
468    /// databases and the second query would not see the first one's table.
469    /// Pinning one name here keeps `sqlite::memory:` meaning what it means
470    /// everywhere else — one database, private to this store.
471    fn pooled_url(database_url: &str) -> std::borrow::Cow<'_, str> {
472        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
473
474        let anonymous_memory = (database_url.contains(":memory:")
475            || database_url.contains("mode=memory"))
476            && !database_url.contains("cache=shared");
477        if !anonymous_memory {
478            return std::borrow::Cow::Borrowed(database_url);
479        }
480
481        let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
482        std::borrow::Cow::Owned(format!(
483            "sqlite:file:a2a-in-memory-{n}?mode=memory&cache=shared"
484        ))
485    }
486
487    /// Pool options carrying whatever this backend has to be told on every
488    /// connection.
489    ///
490    /// Only SQLite has anything: `foreign_keys` is a per-*connection* pragma
491    /// that SQLite itself defaults to off, so a schema with `ON DELETE CASCADE`
492    /// in it means nothing until each connection turns it on. The cascades do
493    /// work today, because sqlx's `SqliteConnectOptions` defaults it on — but
494    /// that is sqlx's default rather than this crate's, and the `Any` driver
495    /// rejects SQLite's URL parameters, so there is nowhere else a caller or
496    /// this adapter could say it. The migrations write the constraints; this is
497    /// what makes them hold. PostgreSQL enforces its own without being asked,
498    /// and would not accept a `PRAGMA`.
499    fn pool_options(dialect: Dialect) -> AnyPoolOptions {
500        match dialect {
501            Dialect::Sqlite => AnyPoolOptions::new().after_connect(|conn, _meta| {
502                Box::pin(async move {
503                    sqlx::query("PRAGMA foreign_keys = ON")
504                        .execute(conn)
505                        .await?;
506                    Ok(())
507                })
508            }),
509            Dialect::Postgres => AnyPoolOptions::new(),
510        }
511    }
512
513    /// Open the pool and bring the schema up to date.
514    async fn connect(
515        database_url: &str,
516        settings: &PoolSettings,
517    ) -> Result<(AnyPool, Dialect), A2AError> {
518        let dialect = Self::dialect_for(database_url)?;
519
520        // The `Any` driver dispatches on the URL scheme at runtime, and it
521        // panics if no driver was registered. Guarded by a `Once` inside sqlx,
522        // so every constructor can call it.
523        sqlx::any::install_default_drivers();
524
525        let url = match dialect {
526            Dialect::Sqlite => Self::pooled_url(database_url),
527            Dialect::Postgres => std::borrow::Cow::Borrowed(database_url),
528        };
529        let pool = Self::pool_options(dialect)
530            .max_connections(settings.max_connections)
531            .acquire_timeout(settings.acquire_timeout)
532            .connect_with(settings.connect_options(&url)?)
533            .await
534            .map_err(|e| A2AError::DatabaseError(format!("Failed to connect to database: {e}")))?;
535
536        // The migrations get a pool of their own, capped at one connection, and
537        // that cap is what makes the lock possible: an advisory lock belongs to
538        // a session, and a one-connection pool *is* a session — reachable
539        // through `&pool`, which is the only way to execute anything here (see
540        // `run_base_migrations`). Opened after the main pool so an in-memory
541        // SQLite database, which lives only as long as something is connected to
542        // it, is already held open by the pool that will keep using it.
543        let migrations = Self::pool_options(dialect)
544            .max_connections(1)
545            .acquire_timeout(settings.acquire_timeout)
546            .connect_with(settings.connect_options(&url)?)
547            .await
548            .map_err(|e| {
549                A2AError::DatabaseError(format!("Failed to open the migration connection: {e}"))
550            })?;
551        let migrated = Self::run_base_migrations(migrations.clone(), dialect).await;
552        migrations.close().await;
553        migrated?;
554
555        Ok((pool, dialect))
556    }
557
558    /// Create a new SQLx task storage with the given database URL.
559    ///
560    /// The scheme picks the backend: `sqlite::memory:`, `sqlite:data.db`, or
561    /// `postgres://user:pass@host/db`. Each needs its cargo feature (`sqlite`,
562    /// `postgres`) compiled in, and the error says so when it is missing.
563    ///
564    /// Pool sizing, statement logging, a custom push sender and agent-specific
565    /// migrations go through [`builder`](Self::builder).
566    pub async fn new(database_url: &str) -> Result<Self, A2AError> {
567        Self::builder(database_url).connect().await
568    }
569
570    /// Configure a store before opening it.
571    ///
572    /// ```no_run
573    /// # use a2a_rs::adapter::storage::SqlxTaskStorage;
574    /// # async fn f() -> Result<(), a2a_rs::domain::A2AError> {
575    /// let storage = SqlxTaskStorage::builder("postgres://user:pass@localhost/a2a")
576    ///     .max_connections(20)
577    ///     .log_statements(true)
578    ///     .connect()
579    ///     .await?;
580    /// # Ok(()) }
581    /// ```
582    pub fn builder(database_url: impl Into<String>) -> SqlxStorageBuilder {
583        SqlxStorageBuilder {
584            url: database_url.into(),
585            pool: PoolSettings::default(),
586            push_sender: None,
587            additional_migrations: Vec::new(),
588            event_log_capacity: Some(DEFAULT_EVENT_LOG_CAPACITY),
589            read_refresh: ReadRefresh::never(),
590            claim_cache_ttl: Some(DEFAULT_CLAIM_CACHE_TTL),
591        }
592    }
593
594    /// Run base A2A framework migrations, in the dialect the URL selected.
595    ///
596    /// These re-run on every construction, so every file has to be idempotent —
597    /// which is what the legacy-table probe and `tolerates_existing_column` are
598    /// for. `raw_sql` rather than `query`: a migration file holds several
599    /// statements, and PostgreSQL only accepts those unprepared.
600    ///
601    /// `pool` is the one-connection migration pool, so on PostgreSQL an
602    /// advisory lock taken through it holds for everything that follows: a
603    /// fleet starting together is the normal case for a shared database, and
604    /// concurrent `CREATE TABLE IF NOT EXISTS` on related tables does not
605    /// no-op — it deadlocks, or fails on the catalog's unique index.
606    ///
607    /// Everything runs through the pool and never on a borrowed connection.
608    /// sqlx implements `Executor` for `&'c mut AnyConnection` at a single
609    /// lifetime, so a future holding such a borrow cannot be proved `Send` by a
610    /// caller that spawns — which `korps-fleet up` does for every agent — and the whole
611    /// construction path would stop compiling for anyone who spawns it.
612    ///
613    /// Owned `AnyPool` here and in every helper below, cloned per call — it is
614    /// an `Arc` inside. A borrowed parameter would make each of these futures
615    /// generic over that lifetime, which is the same shape callers cannot prove.
616    async fn run_base_migrations(pool: AnyPool, dialect: Dialect) -> Result<(), A2AError> {
617        if let Some(lock) = dialect.migration_lock() {
618            sqlx::raw_sql(lock).execute(&pool).await.map_err(|e| {
619                A2AError::DatabaseError(format!("Failed to take the migration lock: {e}"))
620            })?;
621        }
622        // No explicit unlock: the caller closes this pool, which ends the
623        // session, which releases the lock. An unlock statement would be one
624        // more thing to get wrong on the error path.
625
626        let [initial, push_configs, rest @ ..] = dialect.migrations();
627
628        Self::run_migration(pool.clone(), initial).await?;
629        // Between 001 and 002: 001 may have just created the v0.2 table, and 002
630        // creates the one that replaces it.
631        Self::drop_legacy_push_configs(pool.clone(), dialect).await?;
632        Self::run_migration(pool.clone(), push_configs).await?;
633        for migration in rest {
634            Self::run_migration(pool.clone(), migration).await?;
635        }
636
637        // The 004 backfill. Guarded by `context_id IS NULL` rather than run only
638        // when the column was just added: that is idempotent on both backends,
639        // and on an already-migrated database it matches nothing.
640        sqlx::raw_sql(
641            "UPDATE task_history SET context_id = \
642             (SELECT context_id FROM tasks WHERE tasks.id = task_history.task_id) \
643             WHERE context_id IS NULL",
644        )
645        .execute(&pool)
646        .await
647        .map_err(|e| A2AError::DatabaseError(format!("Migration 004 backfill failed: {e}")))?;
648
649        Self::drop_dead_context_state_column(pool.clone(), dialect).await;
650
651        Ok(())
652    }
653
654    /// Drop `contexts.state`, which 005 created and nothing ever wrote.
655    ///
656    /// The state bag went to its own table in 006, so the column is dead on a
657    /// database old enough to have it. Best effort on purpose: an unused column
658    /// costs nothing, and `ALTER TABLE … DROP COLUMN` has enough conditions
659    /// attached on SQLite that failing it must not stop an agent from starting.
660    async fn drop_dead_context_state_column(pool: AnyPool, dialect: Dialect) {
661        let probe = sqlx::query(dialect.dead_context_state_column_probe())
662            .fetch_optional(&pool)
663            .await;
664        if !matches!(probe, Ok(Some(_))) {
665            return;
666        }
667
668        if let Err(e) = sqlx::raw_sql("ALTER TABLE contexts DROP COLUMN state")
669            .execute(&pool)
670            .await
671        {
672            #[cfg(feature = "tracing")]
673            tracing::debug!("left the unused contexts.state column in place: {e}");
674            #[cfg(not(feature = "tracing"))]
675            let _ = e;
676        }
677    }
678
679    /// Run one migration file, once more if another process was running the
680    /// same one.
681    ///
682    /// A shared database is the reason to run PostgreSQL at all, so several
683    /// agents starting together is the normal case — and `CREATE TABLE IF NOT
684    /// EXISTS` checks and creates in two steps, so the loser of that race sees
685    /// the object appear in between and fails on the catalog's unique index
686    /// rather than no-opping. By the retry the winner has finished and every
687    /// statement in the file finds what it wanted already there.
688    async fn run_migration(
689        pool: AnyPool,
690        migration: super::dialect::Migration,
691    ) -> Result<(), A2AError> {
692        let mut attempt = sqlx::raw_sql(migration.sql).execute(&pool).await;
693        if attempt
694            .as_ref()
695            .err()
696            .is_some_and(super::dialect::is_concurrent_ddl_conflict)
697        {
698            attempt = sqlx::raw_sql(migration.sql).execute(&pool).await;
699        }
700
701        match attempt {
702            Ok(_) => Ok(()),
703            // An `ALTER TABLE ADD COLUMN` this dialect cannot write
704            // idempotently, run a second time. The column being there is the
705            // outcome the migration wanted.
706            Err(e)
707                if migration.tolerates_existing_column
708                    && e.to_string().contains("duplicate column name") =>
709            {
710                Ok(())
711            }
712            Err(e) => Err(A2AError::DatabaseError(format!(
713                "Migration {} failed: {e}",
714                migration.name
715            ))),
716        }
717    }
718
719    /// Drop the v0.2 push-config table, and only when it is still the v0.2 one.
720    ///
721    /// Migration 002 replaces that table, and it used to do the drop itself —
722    /// but base migrations re-run on every startup, so every restart destroyed
723    /// the push configs the agent had stored. The probe makes the drop happen
724    /// once, on the database that actually needs it.
725    async fn drop_legacy_push_configs(pool: AnyPool, dialect: Dialect) -> Result<(), A2AError> {
726        let legacy = sqlx::query(dialect.legacy_push_config_probe())
727            .fetch_optional(&pool)
728            .await
729            .map_err(|e| {
730                A2AError::DatabaseError(format!("Failed to inspect push config table: {e}"))
731            })?;
732
733        if legacy.is_some() {
734            sqlx::raw_sql("DROP TABLE IF EXISTS push_notification_configs")
735                .execute(&pool)
736                .await
737                .map_err(|e| {
738                    A2AError::DatabaseError(format!(
739                        "Failed to drop the pre-v0.3 push config table: {e}"
740                    ))
741                })?;
742        }
743        Ok(())
744    }
745
746    /// Run additional migrations provided by the application
747    async fn run_additional_migrations(
748        pool: &AnyPool,
749        migrations: &[String],
750    ) -> Result<(), A2AError> {
751        for (i, migration_sql) in migrations.iter().enumerate() {
752            sqlx::raw_sql(migration_sql)
753                .execute(pool)
754                .await
755                .map_err(|e| {
756                    A2AError::DatabaseError(format!("Additional migration {} failed: {}", i + 1, e))
757                })?;
758        }
759        Ok(())
760    }
761
762    /// Render a query for this store's backend.
763    ///
764    /// Every query in this file goes through here, which is where `?` becomes
765    /// `$1..$n` on PostgreSQL.
766    fn sql<'a>(&self, sql: &'a str) -> std::borrow::Cow<'a, str> {
767        self.dialect.bind_params(sql)
768    }
769
770    /// Convert database row to Task
771    fn row_to_task(row: &sqlx::any::AnyRow) -> Result<Task, A2AError> {
772        let task_id: String = row
773            .try_get("id")
774            .map_err(|e| A2AError::DatabaseError(format!("Failed to get task_id: {}", e)))?;
775        let context_id: String = row
776            .try_get("context_id")
777            .map_err(|e| A2AError::DatabaseError(format!("Failed to get context_id: {}", e)))?;
778        let status_state: String = row
779            .try_get("status_state")
780            .map_err(|e| A2AError::DatabaseError(format!("Failed to get status_state: {}", e)))?;
781        let status_message_json: Option<String> = row
782            .try_get("status_message")
783            .map_err(|e| A2AError::DatabaseError(format!("Failed to get status_message: {}", e)))?;
784        let metadata_json: Option<String> = row
785            .try_get("metadata")
786            .map_err(|e| A2AError::DatabaseError(format!("Failed to get metadata: {}", e)))?;
787        let artifacts_json: Option<String> = row
788            .try_get("artifacts")
789            .map_err(|e| A2AError::DatabaseError(format!("Failed to get artifacts: {}", e)))?;
790
791        // Parse task state
792        let state = match status_state.as_str() {
793            "submitted" => TaskState::Submitted,
794            "working" => TaskState::Working,
795            "input-required" => TaskState::InputRequired,
796            "completed" => TaskState::Completed,
797            "canceled" => TaskState::Canceled,
798            "failed" => TaskState::Failed,
799            "rejected" => TaskState::Rejected,
800            "auth-required" => TaskState::AuthRequired,
801            "unknown" => TaskState::Unknown,
802            _ => TaskState::Unknown,
803        };
804
805        // Parse status message
806        let status_message = if let Some(msg_str) = status_message_json {
807            Some(serde_json::from_str(&msg_str).map_err(|e| {
808                A2AError::DatabaseError(format!("Failed to parse status message: {}", e))
809            })?)
810        } else {
811            None
812        };
813
814        // Parse metadata
815        let metadata =
816            if let Some(meta_str) = metadata_json {
817                Some(serde_json::from_str(&meta_str).map_err(|e| {
818                    A2AError::DatabaseError(format!("Failed to parse metadata: {}", e))
819                })?)
820            } else {
821                None
822            };
823
824        // Parse artifacts
825        let artifacts = if let Some(artifacts_str) = artifacts_json {
826            Some(serde_json::from_str(&artifacts_str).map_err(|e| {
827                A2AError::DatabaseError(format!("Failed to parse artifacts: {}", e))
828            })?)
829        } else {
830            None
831        };
832
833        let now = chrono::Utc::now();
834        let task_status = TaskStatus {
835            state: ::buffa::EnumValue::from(state),
836            message: status_message.into(),
837            timestamp: ::buffa::MessageField::some(::buffa_types::google::protobuf::Timestamp {
838                seconds: now.timestamp(),
839                nanos: now.timestamp_subsec_nanos() as i32,
840                ..Default::default()
841            }),
842            ..Default::default()
843        };
844
845        let task = Task {
846            id: task_id.clone(),
847            context_id,
848            status: ::buffa::MessageField::some(task_status),
849            history: Vec::new(),
850            metadata: metadata.into(),
851            artifacts: artifacts.unwrap_or_default(),
852            ..Default::default()
853        };
854
855        Ok(task)
856    }
857
858    /// Load task history from database
859    async fn load_task_history(
860        &self,
861        task_id: &str,
862        limit: Option<u32>,
863    ) -> Result<Vec<Message>, A2AError> {
864        // Ordered by `id`, not `timestamp`: the timestamp default is
865        // `datetime('now')`, which SQLite resolves to the second, so rows written
866        // in the same second have no defined relative order. `id` is the
867        // autoincrement insertion order and is what the conversation log means by
868        // sequence.
869        //
870        // `message IS NOT NULL` is inside the query rather than a filter on the
871        // rows, so `limit` counts messages. Filtering afterwards made
872        // `history_length = 5` return fewer than five whenever a status
873        // transition carried no message.
874        let query_str = if let Some(limit) = limit {
875            format!(
876                "SELECT id, status_state, message FROM task_history \
877                 WHERE task_id = ? AND message IS NOT NULL ORDER BY id DESC LIMIT {}",
878                limit
879            )
880        } else {
881            "SELECT id, status_state, message FROM task_history \
882             WHERE task_id = ? AND message IS NOT NULL ORDER BY id DESC"
883                .to_string()
884        };
885
886        let query_str = self.sql(&query_str);
887        let rows = sqlx::query(&query_str)
888            .bind(task_id)
889            .fetch_all(&self.pool)
890            .await
891            .map_err(|e| A2AError::DatabaseError(format!("Failed to load task history: {}", e)))?;
892
893        let mut history = Vec::new();
894        for row in rows {
895            let message_json: Option<String> = row.try_get("message").map_err(|e| {
896                A2AError::DatabaseError(format!("Failed to get message from history: {}", e))
897            })?;
898
899            if let Some(msg_str) = message_json {
900                let message: Message = serde_json::from_str(&msg_str).map_err(|e| {
901                    A2AError::DatabaseError(format!("Failed to parse message from history: {}", e))
902                })?;
903                history.push(message);
904            }
905        }
906
907        // Reverse to get chronological order
908        history.reverse();
909        Ok(history)
910    }
911
912    /// Add entry to task history
913    async fn add_to_history(
914        &self,
915        task_id: &str,
916        state: TaskState,
917        message: Option<Message>,
918    ) -> Result<(), A2AError> {
919        let state_str = state_str(state);
920
921        let message_json = if let Some(msg) = message {
922            Some(serde_json::to_string(&msg).map_err(|e| {
923                A2AError::DatabaseError(format!("Failed to serialize message: {}", e))
924            })?)
925        } else {
926            None
927        };
928
929        // `context_id` is denormalized from `tasks` at insert rather than joined
930        // at read: a task's context never changes, and rebuilding a conversation
931        // for the model is the hottest read there is.
932        let sql = self.sql(
933            "INSERT INTO task_history (task_id, context_id, status_state, message) \
934             VALUES (?, (SELECT context_id FROM tasks WHERE id = ?), ?, ?)",
935        );
936        sqlx::query(&sql)
937            .bind(task_id)
938            .bind(task_id)
939            .bind(state_str)
940            .bind(message_json)
941            .execute(&self.pool)
942            .await
943            .map_err(|e| A2AError::DatabaseError(format!("Failed to add task history: {}", e)))?;
944
945        Ok(())
946    }
947
948    /// Claim `context_id` for `caller` if nobody holds it, then refuse a caller
949    /// that is not the holder.
950    ///
951    /// Ownership is first-write and never changes afterwards, so an existing
952    /// row is the whole answer and only its absence needs a write. That is why
953    /// the read comes first: every turn after the one that opened a context
954    /// settles this in a single statement, on a path a handler takes twice a
955    /// turn (the conversation and the state bag).
956    ///
957    /// An unowned context — one claimed with no principal, which is what an
958    /// agent running without an authenticator produces — stays readable by
959    /// anyone.
960    async fn claim_or_check_context(
961        &self,
962        context_id: &str,
963        caller: Option<&str>,
964    ) -> Result<(), A2AError> {
965        if let Some(claim) = self.read_claim(context_id).await? {
966            return claim.verdict(context_id, caller);
967        }
968
969        // Nothing holds it. The insert ignores a conflict, so a caller arriving
970        // second cannot take a context over.
971        sqlx::query(self.dialect.insert_context_if_absent())
972            .bind(context_id)
973            .bind(caller)
974            .execute(&self.pool)
975            .await
976            .map_err(|e| A2AError::DatabaseError(format!("Failed to register context: {}", e)))?;
977
978        // Read back rather than assuming the insert was ours: two callers can
979        // open one context in the same instant, and the loser has to be refused.
980        // `rows_affected` would settle it without this statement, and would rest
981        // an access decision on how each driver counts an ignored insert.
982        match self.read_claim(context_id).await? {
983            Some(claim) => claim.verdict(context_id, caller),
984            None => Ok(()),
985        }
986    }
987
988    /// Read the claim on a context, or `None` if it has no row yet.
989    ///
990    /// The one place the [`ClaimCache`] is consulted, which is what keeps it out
991    /// of the claiming path's structure: `claim_or_check_context` still reads
992    /// before it writes and still reads back afterwards rather than trusting
993    /// `rows_affected`, and the read-back is what puts a newly opened context in
994    /// the cache. Only a settled answer is cached — `None` here means the caller
995    /// has to write, and a decision nobody has made yet is not one to remember.
996    async fn read_claim(&self, context_id: &str) -> Result<Option<ContextClaim>, A2AError> {
997        if let Some(claim) = self.claim_cache.as_ref().and_then(|c| c.get(context_id)) {
998            return Ok(Some(claim));
999        }
1000
1001        let sql = self.sql("SELECT owner FROM contexts WHERE id = ?");
1002        let row = sqlx::query(&sql)
1003            .bind(context_id)
1004            .fetch_optional(&self.pool)
1005            .await
1006            .map_err(|e| A2AError::DatabaseError(format!("Failed to read context owner: {}", e)))?;
1007
1008        let Some(row) = row else {
1009            return Ok(None);
1010        };
1011        let owner: Option<String> = row
1012            .try_get("owner")
1013            .map_err(|e| A2AError::DatabaseError(format!("Failed to get context owner: {}", e)))?;
1014
1015        let claim = match owner {
1016            Some(owner) => ContextClaim::Owner(owner),
1017            None => ContextClaim::Open,
1018        };
1019        if let Some(cache) = self.claim_cache.as_ref() {
1020            cache.insert(context_id, &claim);
1021        }
1022        Ok(Some(claim))
1023    }
1024
1025    /// Let this read count as keeping `principal`'s `user:` bag alive, if the
1026    /// store was configured to and the bag is old enough to need it.
1027    ///
1028    /// The one place a read writes, and it writes at most once per principal per
1029    /// window however often it is read — the whole point of
1030    /// [`ReadRefresh`] carrying a window rather than a flag. Off by default, so
1031    /// a store that was not told otherwise runs the statement never.
1032    ///
1033    /// The cutoff goes in through [`Dialect::format_timestamp`], the same way a
1034    /// sweep's does, so the two compare against `updated_at` identically.
1035    async fn refresh_user_state(&self, principal: &str) -> Result<(), A2AError> {
1036        let Some(cutoff) = self.read_refresh.cutoff(chrono::Utc::now()) else {
1037            return Ok(());
1038        };
1039
1040        sqlx::query(self.dialect.refresh_user_state())
1041            .bind(principal)
1042            .bind(self.dialect.format_timestamp(cutoff))
1043            .execute(&self.pool)
1044            .await
1045            .map_err(|e| A2AError::DatabaseError(format!("Failed to refresh user state: {}", e)))?;
1046        Ok(())
1047    }
1048
1049    /// Hand out this store's push-notification registry as an
1050    /// [`AsyncPushNotifier`].
1051    ///
1052    /// The returned notifier shares the same config registry the store writes to
1053    /// via [`AsyncNotificationManager::set_config`], so a config registered on
1054    /// the store is immediately visible to the notifier at the composition edge.
1055    pub fn push_notifier(&self) -> Arc<dyn AsyncPushNotifier> {
1056        self.push_notification_registry.clone()
1057    }
1058
1059    /// The connection cap the pool was opened with — what
1060    /// [`SqlxStorageBuilder::max_connections`] asked for, read back off the
1061    /// pool. On PostgreSQL this is the store's share of a server-wide limit.
1062    pub fn max_connections(&self) -> u32 {
1063        self.pool.options().get_max_connections()
1064    }
1065}
1066
1067#[cfg(feature = "sqlx-storage")]
1068#[async_trait]
1069impl AsyncTaskLifecycle for SqlxTaskStorage {
1070    async fn create(&self, id: &TaskId, context_id: &ContextId) -> Result<Task, A2AError> {
1071        let task_id = id.as_str();
1072        let context_id = context_id.as_str();
1073        // Check if task already exists
1074        let exists_sql = self.sql("SELECT id FROM tasks WHERE id = ?");
1075        let existing = sqlx::query(&exists_sql)
1076            .bind(task_id)
1077            .fetch_optional(&self.pool)
1078            .await
1079            .map_err(|e| {
1080                A2AError::DatabaseError(format!("Failed to check existing task: {}", e))
1081            })?;
1082
1083        if existing.is_some() {
1084            return Err(A2AError::TaskNotFound(format!(
1085                "Task {} already exists",
1086                task_id
1087            )));
1088        }
1089
1090        // Create new task
1091        let task = Task::new(task_id.to_string(), context_id.to_string());
1092
1093        // Convert metadata and artifacts to JSON strings
1094        let metadata_json = task
1095            .metadata
1096            .as_option()
1097            .map(|m| serde_json::to_string(m).unwrap_or_default());
1098        let artifacts_json = serde_json::to_string(&task.artifacts).unwrap_or_default();
1099        let status_message_str = task
1100            .status
1101            .as_option()
1102            .and_then(|s| s.message.as_option())
1103            .map(|m| serde_json::to_string(m).unwrap_or_default());
1104
1105        // Insert into database
1106        let insert_sql = self.sql(
1107            "INSERT INTO tasks (id, context_id, status_state, status_message, metadata, artifacts) \
1108             VALUES (?, ?, ?, ?, ?, ?)",
1109        );
1110        sqlx::query(&insert_sql)
1111            .bind(&task.id)
1112            .bind(&task.context_id)
1113            .bind("submitted")
1114            .bind(status_message_str)
1115            .bind(metadata_json)
1116            .bind(artifacts_json)
1117            .execute(&self.pool)
1118            .await
1119            .map_err(|e| A2AError::DatabaseError(format!("Failed to create task: {}", e)))?;
1120
1121        // Add initial history entry
1122        self.add_to_history(task_id, TaskState::Submitted, None)
1123            .await?;
1124
1125        Ok(task)
1126    }
1127
1128    async fn update_status(
1129        &self,
1130        id: &TaskId,
1131        state: TaskState,
1132        message: Option<Message>,
1133    ) -> Result<Task, A2AError> {
1134        let task_id = id.as_str();
1135        let state_str = state_str(state);
1136
1137        // Update task in database (bump the optimistic-concurrency version).
1138        // `status_message` goes with the state: it used to be written only by
1139        // `create` and never again, so a completed task read back from here had
1140        // the state right and no message, while the in-memory store — and the
1141        // domain's own `Task::update_status` — carried the agent's reply.
1142        let sql = self.sql(
1143            "UPDATE tasks SET status_state = ?, status_message = ?, version = version + 1 \
1144             WHERE id = ?",
1145        );
1146        let result = sqlx::query(&sql)
1147            .bind(state_str)
1148            .bind(status_message_json(message.as_ref()))
1149            .bind(task_id)
1150            .execute(&self.pool)
1151            .await
1152            .map_err(|e| A2AError::DatabaseError(format!("Failed to update task status: {}", e)))?;
1153
1154        if result.rows_affected() == 0 {
1155            return Err(A2AError::TaskNotFound(task_id.to_string()));
1156        }
1157
1158        // Add to history
1159        self.add_to_history(task_id, state, message).await?;
1160
1161        // Persistence only: announcing the change to streaming subscribers is
1162        // the orchestration layer's job (see `TaskStatusBroadcast`), not a side
1163        // effect of the mutator.
1164        self.get(id, None).await
1165    }
1166
1167    async fn exists(&self, id: &TaskId) -> Result<bool, A2AError> {
1168        let task_id = id.as_str();
1169        let sql = self.sql("SELECT id FROM tasks WHERE id = ?");
1170        let row = sqlx::query(&sql)
1171            .bind(task_id)
1172            .fetch_optional(&self.pool)
1173            .await
1174            .map_err(|e| {
1175                A2AError::DatabaseError(format!("Failed to check task existence: {}", e))
1176            })?;
1177
1178        Ok(row.is_some())
1179    }
1180
1181    async fn get(&self, id: &TaskId, history_length: Option<u32>) -> Result<Task, A2AError> {
1182        let task_id = id.as_str();
1183        // Get task from database
1184        let query_str = format!("SELECT {TASK_COLUMNS} FROM tasks WHERE id = ?");
1185        let sql = self.sql(&query_str);
1186        let row = sqlx::query(&sql)
1187            .bind(task_id)
1188            .fetch_optional(&self.pool)
1189            .await
1190            .map_err(|e| A2AError::DatabaseError(format!("Failed to get task: {}", e)))?;
1191
1192        let Some(row) = row else {
1193            return Err(A2AError::TaskNotFound(task_id.to_string()));
1194        };
1195
1196        let mut task = Self::row_to_task(&row)?;
1197
1198        // Load history
1199        if history_length.is_some() || history_length.is_none() {
1200            let history = self.load_task_history(task_id, history_length).await?;
1201            task.history = history;
1202        }
1203
1204        Ok(task)
1205    }
1206
1207    async fn cancel(&self, id: &TaskId) -> Result<Task, A2AError> {
1208        let task_id = id.as_str();
1209        // Get current task
1210        let task = self.get(id, None).await?;
1211
1212        // Anything that has not finished can be canceled — a queued
1213        // (`Submitted`) task most of all, and an `InputRequired` one, where
1214        // cancelling is how a client says "never mind". See
1215        // `TaskState::is_cancelable`.
1216        if !task.status.state.is_cancelable() {
1217            return Err(A2AError::TaskNotCancelable(format!(
1218                "Task {} has already finished in state {:?} and cannot be canceled",
1219                task_id, task.status.state
1220            )));
1221        }
1222
1223        // Create a cancellation message
1224        let mut cancel_message = Message::agent_text(
1225            format!("Task {} canceled.", task_id),
1226            uuid::Uuid::new_v4().to_string(),
1227        );
1228        cancel_message.task_id = task_id.to_string();
1229        cancel_message.context_id = task.context_id.clone();
1230
1231        // Update task status (bump the optimistic-concurrency version). The
1232        // cancellation message is the status message, which is what the
1233        // in-memory store does by handing it to `Task::update_status` — here it
1234        // reached history only, so a canceled task could not say why.
1235        let sql = self.sql(
1236            "UPDATE tasks SET status_state = ?, status_message = ?, version = version + 1 \
1237             WHERE id = ?",
1238        );
1239        sqlx::query(&sql)
1240            .bind(state_str(TaskState::Canceled))
1241            .bind(status_message_json(Some(&cancel_message)))
1242            .bind(task_id)
1243            .execute(&self.pool)
1244            .await
1245            .map_err(|e| A2AError::DatabaseError(format!("Failed to cancel task: {}", e)))?;
1246
1247        // Add to history with cancellation message
1248        self.add_to_history(task_id, TaskState::Canceled, Some(cancel_message))
1249            .await?;
1250
1251        // Persistence only: the orchestration layer announces the cancellation
1252        // to streaming subscribers (see `TaskStatusBroadcast`).
1253        self.get(id, None).await
1254    }
1255}
1256
1257#[cfg(feature = "sqlx-storage")]
1258impl SqlxTaskStorage {
1259    /// Read the current stored version of a task, or `None` if it doesn't exist.
1260    async fn current_version(&self, task_id: &str) -> Result<Option<u64>, A2AError> {
1261        let sql = self.sql("SELECT version FROM tasks WHERE id = ?");
1262        let row = sqlx::query(&sql)
1263            .bind(task_id)
1264            .fetch_optional(&self.pool)
1265            .await
1266            .map_err(|e| A2AError::DatabaseError(format!("Failed to read task version: {}", e)))?;
1267        match row {
1268            Some(row) => {
1269                let v: i64 = row.try_get("version").map_err(|e| {
1270                    A2AError::DatabaseError(format!("Failed to get version column: {}", e))
1271                })?;
1272                Ok(Some(v as u64))
1273            }
1274            None => Ok(None),
1275        }
1276    }
1277}
1278
1279#[cfg(feature = "sqlx-storage")]
1280#[async_trait]
1281impl AsyncTaskVersioning for SqlxTaskStorage {
1282    async fn version(&self, id: &TaskId) -> Result<u64, A2AError> {
1283        self.current_version(id.as_str())
1284            .await?
1285            .ok_or_else(|| A2AError::TaskNotFound(id.as_str().to_string()))
1286    }
1287
1288    async fn get_versioned(
1289        &self,
1290        id: &TaskId,
1291        history_length: Option<u32>,
1292    ) -> Result<VersionedTask, A2AError> {
1293        let task = self.get(id, history_length).await?;
1294        let version = self.version(id).await?;
1295        Ok(VersionedTask::new(task, version))
1296    }
1297
1298    async fn update_status_checked(
1299        &self,
1300        id: &TaskId,
1301        expected: u64,
1302        state: TaskState,
1303        message: Option<Message>,
1304    ) -> Result<VersionedTask, A2AError> {
1305        let task_id = id.as_str();
1306        let state_str = state_str(state);
1307
1308        // Conditional update: both backends apply it atomically, so the row
1309        // count tells us whether the version matched without a separate lock.
1310        let sql = self.sql(
1311            "UPDATE tasks SET status_state = ?, status_message = ?, version = version + 1 \
1312             WHERE id = ? AND version = ?",
1313        );
1314        let result = sqlx::query(&sql)
1315            .bind(state_str)
1316            .bind(status_message_json(message.as_ref()))
1317            .bind(task_id)
1318            .bind(expected as i64)
1319            .execute(&self.pool)
1320            .await
1321            .map_err(|e| A2AError::DatabaseError(format!("Failed to update task status: {}", e)))?;
1322
1323        if result.rows_affected() == 0 {
1324            // No row matched: either the task is gone or the version moved on.
1325            return match self.current_version(task_id).await? {
1326                Some(actual) => Err(A2AError::VersionConflict {
1327                    id: task_id.to_string(),
1328                    expected,
1329                    actual,
1330                }),
1331                None => Err(A2AError::TaskNotFound(task_id.to_string())),
1332            };
1333        }
1334
1335        self.add_to_history(task_id, state, message).await?;
1336        let task = self.get(id, None).await?;
1337        Ok(VersionedTask::new(task, expected + 1))
1338    }
1339}
1340
1341#[cfg(feature = "sqlx-storage")]
1342#[async_trait]
1343impl AsyncTaskQuery for SqlxTaskStorage {
1344    async fn list(
1345        &self,
1346        params: &crate::domain::ListTasksParams,
1347    ) -> Result<crate::domain::ListTasksResult, A2AError> {
1348        use crate::domain::ListTasksResult;
1349
1350        // Build WHERE clause conditions
1351        let mut where_conditions = Vec::new();
1352
1353        // Filter by context_id
1354        if params.context_id.is_some() {
1355            where_conditions.push("context_id = ?".to_string());
1356        }
1357
1358        // Filter by status
1359        if params.status.is_some() {
1360            where_conditions.push("status_state = ?".to_string());
1361        }
1362
1363        // Filter by status_timestamp_after. Both the predicate and the value
1364        // are the dialect's, since one backend keeps its timestamps as text and
1365        // the other as a timestamp the parameter has to be cast to.
1366        let timestamp_str = if let Some(status_timestamp_after) = &params.status_timestamp_after {
1367            // Parse ISO 8601 string
1368            let timestamp =
1369                chrono::DateTime::parse_from_rfc3339(status_timestamp_after).map_err(|e| {
1370                    A2AError::DatabaseError(format!(
1371                        "Invalid timestamp value: {} ({})",
1372                        status_timestamp_after, e
1373                    ))
1374                })?;
1375            where_conditions.push(self.dialect.updated_since_predicate().to_string());
1376            Some(
1377                self.dialect
1378                    .format_timestamp(timestamp.with_timezone(&chrono::Utc)),
1379            )
1380        } else {
1381            None
1382        };
1383
1384        // Build WHERE clause
1385        let where_clause = if where_conditions.is_empty() {
1386            String::new()
1387        } else {
1388            format!(" WHERE {}", where_conditions.join(" AND "))
1389        };
1390
1391        // First, get total count with same filters
1392        let count_sql = format!("SELECT COUNT(*) as count FROM tasks{}", where_clause);
1393        let count_query = self.sql(&count_sql);
1394        let mut count_q = sqlx::query(&count_query);
1395
1396        // Bind parameters for count query
1397        if let Some(ref context_id) = params.context_id {
1398            count_q = count_q.bind(context_id);
1399        }
1400        if let Some(ref status) = params.status {
1401            let state_str = state_str(*status);
1402            count_q = count_q.bind(state_str);
1403        }
1404        if let Some(ref ts) = timestamp_str {
1405            count_q = count_q.bind(ts);
1406        }
1407
1408        let count_row = count_q
1409            .fetch_one(&self.pool)
1410            .await
1411            .map_err(|e| A2AError::DatabaseError(format!("Failed to count tasks: {}", e)))?;
1412
1413        // Read as 64-bit: `COUNT(*)` is a bigint on PostgreSQL and an integer
1414        // wide enough to be one on SQLite, and the driver will not narrow it.
1415        let total_size: i32 = count_row
1416            .try_get::<i64, _>("count")
1417            .map_err(|e| A2AError::DatabaseError(format!("Failed to get count: {}", e)))?
1418            .try_into()
1419            .unwrap_or(i32::MAX);
1420
1421        // Handle pagination
1422        let page_size = params.page_size.unwrap_or(50).clamp(1, 100);
1423        let offset = if let Some(ref token) = params.page_token {
1424            token.parse::<i32>().unwrap_or(0)
1425        } else {
1426            0
1427        };
1428
1429        // Build main query with LIMIT and OFFSET
1430        let main_sql = format!(
1431            "SELECT {TASK_COLUMNS} FROM tasks{} ORDER BY updated_at DESC LIMIT ? OFFSET ?",
1432            where_clause
1433        );
1434        let main_query = self.sql(&main_sql);
1435
1436        let mut main_q = sqlx::query(&main_query);
1437
1438        // Bind parameters for main query
1439        if let Some(ref context_id) = params.context_id {
1440            main_q = main_q.bind(context_id);
1441        }
1442        if let Some(ref status) = params.status {
1443            let state_str = state_str(*status);
1444            main_q = main_q.bind(state_str);
1445        }
1446        if let Some(ref ts) = timestamp_str {
1447            main_q = main_q.bind(ts);
1448        }
1449
1450        // Bind LIMIT and OFFSET
1451        main_q = main_q.bind(page_size).bind(offset);
1452
1453        let rows = main_q
1454            .fetch_all(&self.pool)
1455            .await
1456            .map_err(|e| A2AError::DatabaseError(format!("Failed to list tasks: {}", e)))?;
1457
1458        // Convert rows to tasks
1459        let mut tasks: Vec<Task> = rows
1460            .iter()
1461            .filter_map(|row| Self::row_to_task(row).ok())
1462            .collect();
1463
1464        // Load history for each task if requested
1465        let history_length = params.history_length.unwrap_or(0);
1466        for task in &mut tasks {
1467            if history_length > 0 {
1468                let history = self
1469                    .load_task_history(&task.id, Some(history_length as u32))
1470                    .await?;
1471                task.history = history;
1472            } else {
1473                task.history.clear();
1474            }
1475
1476            // Remove artifacts if not requested
1477            if !params.include_artifacts.unwrap_or(false) {
1478                task.artifacts.clear();
1479            }
1480        }
1481
1482        // Generate next page token
1483        let has_more = offset + page_size < total_size;
1484        let next_page_token = if has_more {
1485            (offset + page_size).to_string()
1486        } else {
1487            String::new()
1488        };
1489
1490        Ok(ListTasksResult {
1491            tasks,
1492            total_size,
1493            page_size,
1494            next_page_token,
1495        })
1496    }
1497}
1498
1499#[cfg(feature = "sqlx-storage")]
1500#[async_trait]
1501impl AsyncNotificationManager for SqlxTaskStorage {
1502    async fn get_config(
1503        &self,
1504        params: &crate::domain::GetTaskPushNotificationConfigParams,
1505    ) -> Result<crate::domain::TaskPushNotificationConfig, A2AError> {
1506        // When a specific config id is supplied, filter by it; otherwise fall
1507        // back to the task's config (single-config-per-task convenience, matching
1508        // the in-memory adapter and the v1.0.0 single-config helpers).
1509        // Note: push_notification_config_id filtering requires migration 002 to be applied.
1510        let by_id = self.sql(
1511            "SELECT id, task_id, url, token, authentication FROM push_notification_configs \
1512             WHERE task_id = ? AND id = ?",
1513        );
1514        let by_task = self.sql(
1515            "SELECT id, task_id, url, token, authentication FROM push_notification_configs \
1516             WHERE task_id = ? ORDER BY id LIMIT 1",
1517        );
1518        let row = match params.push_notification_config_id.as_ref() {
1519            Some(config_id) => sqlx::query(&by_id).bind(&params.id).bind(config_id),
1520            None => sqlx::query(&by_task).bind(&params.id),
1521        }
1522        .fetch_optional(&self.pool)
1523        .await
1524        .map_err(|e| A2AError::DatabaseError(format!("Failed to get push config: {}", e)))?;
1525
1526        if let Some(row) = row {
1527            let id: String = row
1528                .try_get("id")
1529                .map_err(|e| A2AError::DatabaseError(format!("Failed to get config id: {}", e)))?;
1530            let url: String = row
1531                .try_get("url")
1532                .map_err(|e| A2AError::DatabaseError(format!("Failed to get url: {}", e)))?;
1533            let token: Option<String> = row.try_get("token").ok();
1534            let auth_json: Option<String> = row.try_get("authentication").ok();
1535
1536            let auth_info = if let Some(auth_str) = auth_json {
1537                serde_json::from_str(&auth_str).ok()
1538            } else {
1539                None
1540            };
1541
1542            Ok(crate::domain::TaskPushNotificationConfig {
1543                task_id: params.id.clone(),
1544                id,
1545                url,
1546                token: token.unwrap_or_default(),
1547                authentication: auth_info.into(),
1548                tenant: "".to_string(),
1549                ..Default::default()
1550            })
1551        } else {
1552            Err(A2AError::TaskNotFound(format!(
1553                "Push notification config not found for task {}{}",
1554                params.id,
1555                params
1556                    .push_notification_config_id
1557                    .as_ref()
1558                    .map(|id| format!(" with id {}", id))
1559                    .unwrap_or_default()
1560            )))
1561        }
1562    }
1563
1564    async fn list_configs(
1565        &self,
1566        params: &crate::domain::ListTaskPushNotificationConfigsParams,
1567    ) -> Result<Vec<crate::domain::TaskPushNotificationConfig>, A2AError> {
1568        // Query all configs for the task
1569        let sql = self.sql(
1570            "SELECT id, task_id, url, token, authentication FROM push_notification_configs \
1571             WHERE task_id = ?",
1572        );
1573        let rows = sqlx::query(&sql)
1574            .bind(&params.id)
1575            .fetch_all(&self.pool)
1576            .await
1577            .map_err(|e| A2AError::DatabaseError(format!("Failed to list push configs: {}", e)))?;
1578
1579        let configs: Vec<crate::domain::TaskPushNotificationConfig> = rows
1580            .iter()
1581            .filter_map(|row| {
1582                let id: String = row.try_get("id").ok()?;
1583                let url: String = row.try_get("url").ok()?;
1584                let token: Option<String> = row.try_get("token").ok().flatten();
1585                let auth_json: Option<String> = row.try_get("authentication").ok().flatten();
1586
1587                let auth_info = if let Some(auth_str) = auth_json {
1588                    serde_json::from_str(&auth_str).ok()
1589                } else {
1590                    None
1591                };
1592
1593                Some(crate::domain::TaskPushNotificationConfig {
1594                    task_id: params.id.clone(),
1595                    id,
1596                    url,
1597                    token: token.unwrap_or_default(),
1598                    authentication: auth_info.into(),
1599                    tenant: "".to_string(),
1600                    ..Default::default()
1601                })
1602            })
1603            .collect();
1604
1605        Ok(configs)
1606    }
1607
1608    async fn delete_config(
1609        &self,
1610        params: &crate::domain::DeleteTaskPushNotificationConfigParams,
1611    ) -> Result<(), A2AError> {
1612        // Delete the specific config when an id is supplied; otherwise delete all
1613        // configs for the task (single-config-per-task convenience, matching the
1614        // in-memory adapter).
1615        let all_for_task = self.sql("DELETE FROM push_notification_configs WHERE task_id = ?");
1616        let one = self.sql("DELETE FROM push_notification_configs WHERE task_id = ? AND id = ?");
1617        let query = if params.push_notification_config_id.is_empty() {
1618            sqlx::query(&all_for_task).bind(&params.id)
1619        } else {
1620            sqlx::query(&one)
1621                .bind(&params.id)
1622                .bind(&params.push_notification_config_id)
1623        };
1624        let _result = query
1625            .execute(&self.pool)
1626            .await
1627            .map_err(|e| A2AError::DatabaseError(format!("Failed to delete push config: {}", e)))?;
1628
1629        // Idempotent - don't error if already deleted (v1.0.0 spec behavior)
1630        Ok(())
1631    }
1632
1633    async fn set_config(
1634        &self,
1635        config: &TaskPushNotificationConfig,
1636    ) -> Result<TaskPushNotificationConfig, A2AError> {
1637        // Generate ID if not provided
1638        let config_id = if config.id.is_empty() {
1639            uuid::Uuid::new_v4().to_string()
1640        } else {
1641            config.id.clone()
1642        };
1643
1644        // Serialize authentication if present
1645        let auth_json = config
1646            .authentication
1647            .as_option()
1648            .map(|auth| serde_json::to_string(auth).unwrap_or_default());
1649
1650        // Store in database (using new schema with id, token, authentication)
1651        sqlx::query(self.dialect.upsert_push_config())
1652            .bind(&config_id)
1653            .bind(&config.task_id)
1654            .bind(&config.url)
1655            .bind(&config.token)
1656            .bind(auth_json)
1657            .execute(&self.pool)
1658            .await
1659            .map_err(|e| {
1660                A2AError::DatabaseError(format!("Failed to set push notification config: {}", e))
1661            })?;
1662
1663        // Register with the push notification registry
1664        self.push_notification_registry
1665            .register(&config.task_id, config.clone())
1666            .await?;
1667
1668        // Return config with ID set
1669        let mut result_config = config.clone();
1670        result_config.id = config_id;
1671        Ok(result_config)
1672    }
1673}
1674
1675#[cfg(feature = "sqlx-storage")]
1676impl Clone for SqlxTaskStorage {
1677    fn clone(&self) -> Self {
1678        Self {
1679            pool: self.pool.clone(),
1680            dialect: self.dialect,
1681            push_notification_registry: self.push_notification_registry.clone(),
1682            event_log_capacity: self.event_log_capacity,
1683            read_refresh: self.read_refresh,
1684            claim_cache: self.claim_cache.clone(),
1685        }
1686    }
1687}
1688
1689#[cfg(feature = "sqlx-storage")]
1690#[async_trait]
1691impl AsyncConversationStore for SqlxTaskStorage {
1692    async fn load(
1693        &self,
1694        context_id: &ContextId,
1695        caller: Option<&str>,
1696        limit: Option<u32>,
1697    ) -> Result<Conversation, A2AError> {
1698        let context_id = context_id.as_str();
1699        // Claims on read: a handler loads history at the top of every turn, so
1700        // the first turn is what establishes who owns the conversation.
1701        // Claiming only on compaction would leave a context readable by anyone
1702        // until it first grew long enough to summarize.
1703        self.claim_or_check_context(context_id, caller).await?;
1704
1705        // Highest watermark rather than newest row: two turns of one
1706        // conversation can compact concurrently and land out of order, and the
1707        // digest covering more is the one to read from.
1708        let digest_sql = self.sql(
1709            "SELECT covers_through_seq, summary, replaced_messages, model \
1710             FROM context_digests WHERE context_id = ? \
1711             ORDER BY covers_through_seq DESC LIMIT 1",
1712        );
1713        let digest_row = sqlx::query(&digest_sql)
1714            .bind(context_id)
1715            .fetch_optional(&self.pool)
1716            .await
1717            .map_err(|e| {
1718                A2AError::DatabaseError(format!("Failed to load context digest: {}", e))
1719            })?;
1720
1721        let digest = match digest_row {
1722            Some(row) => {
1723                let covers_through: i64 = row.try_get("covers_through_seq").map_err(|e| {
1724                    A2AError::DatabaseError(format!("Failed to get digest watermark: {}", e))
1725                })?;
1726                let summary: String = row.try_get("summary").map_err(|e| {
1727                    A2AError::DatabaseError(format!("Failed to get digest summary: {}", e))
1728                })?;
1729                let replaced_messages: i64 = row.try_get("replaced_messages").map_err(|e| {
1730                    A2AError::DatabaseError(format!("Failed to get digest message count: {}", e))
1731                })?;
1732                let model: String = row.try_get("model").map_err(|e| {
1733                    A2AError::DatabaseError(format!("Failed to get digest model: {}", e))
1734                })?;
1735                Some(Digest {
1736                    covers_through: Seq::new(covers_through.max(0) as u64),
1737                    summary,
1738                    replaced_messages: replaced_messages.max(0) as u32,
1739                    model,
1740                })
1741            }
1742            None => None,
1743        };
1744
1745        let watermark = digest
1746            .as_ref()
1747            .map(|digest| digest.covers_through.get())
1748            .unwrap_or(0) as i64;
1749
1750        // Ordered by `id`, which is the sequence number. Limiting keeps the
1751        // newest — the older end is what a summary stands in for, so dropping
1752        // the recent half would leave the model the least relevant part.
1753        // Hence DESC plus a reverse, rather than an offset the caller cannot
1754        // compute without first counting the rows.
1755        let query = match limit {
1756            Some(limit) => format!(
1757                "SELECT id, message FROM task_history \
1758                 WHERE context_id = ? AND id > ? AND message IS NOT NULL \
1759                 ORDER BY id DESC LIMIT {}",
1760                limit
1761            ),
1762            None => "SELECT id, message FROM task_history \
1763                     WHERE context_id = ? AND id > ? AND message IS NOT NULL \
1764                     ORDER BY id DESC"
1765                .to_string(),
1766        };
1767        let query = self.sql(&query);
1768
1769        let rows = sqlx::query(&query)
1770            .bind(context_id)
1771            .bind(watermark)
1772            .fetch_all(&self.pool)
1773            .await
1774            .map_err(|e| A2AError::DatabaseError(format!("Failed to load conversation: {}", e)))?;
1775
1776        let mut tail = Vec::with_capacity(rows.len());
1777        for row in rows {
1778            let seq: i64 = row.try_get("id").map_err(|e| {
1779                A2AError::DatabaseError(format!("Failed to get history sequence: {}", e))
1780            })?;
1781            let message_json: String = row.try_get("message").map_err(|e| {
1782                A2AError::DatabaseError(format!("Failed to get history message: {}", e))
1783            })?;
1784            let message: Message = serde_json::from_str(&message_json).map_err(|e| {
1785                A2AError::DatabaseError(format!("Failed to parse history message: {}", e))
1786            })?;
1787            tail.push(SequencedMessage {
1788                seq: Seq::new(seq.max(0) as u64),
1789                message,
1790            });
1791        }
1792        tail.reverse();
1793
1794        Ok(Conversation { digest, tail })
1795    }
1796
1797    async fn compact(
1798        &self,
1799        context_id: &ContextId,
1800        caller: Option<&str>,
1801        digest: Digest,
1802    ) -> Result<(), A2AError> {
1803        let context_id = context_id.as_str();
1804        self.claim_or_check_context(context_id, caller).await?;
1805
1806        let sql = self.sql(
1807            "INSERT INTO context_digests \
1808             (context_id, covers_through_seq, summary, replaced_messages, model) \
1809             VALUES (?, ?, ?, ?, ?)",
1810        );
1811        sqlx::query(&sql)
1812            .bind(context_id)
1813            .bind(digest.covers_through.get() as i64)
1814            .bind(&digest.summary)
1815            .bind(digest.replaced_messages as i64)
1816            .bind(&digest.model)
1817            .execute(&self.pool)
1818            .await
1819            .map_err(|e| {
1820                A2AError::DatabaseError(format!("Failed to append context digest: {}", e))
1821            })?;
1822
1823        Ok(())
1824    }
1825}
1826
1827/// How a stored scope is spelled in the `scope` column.
1828///
1829/// This adapter's encoding, not the domain's: [`StateScope`] carries the key
1830/// prefix a model writes, which is not the same string.
1831#[cfg(feature = "sqlx-storage")]
1832fn scope_column(scope: StateScope) -> Option<&'static str> {
1833    match scope {
1834        StateScope::User => Some("user"),
1835        StateScope::Context => Some("context"),
1836        // Never stored. That is the whole content of the scope.
1837        StateScope::Temp => None,
1838    }
1839}
1840
1841#[cfg(feature = "sqlx-storage")]
1842#[async_trait]
1843impl AsyncContextStateStore for SqlxTaskStorage {
1844    async fn load_state(
1845        &self,
1846        context_id: &ContextId,
1847        caller: Option<&str>,
1848    ) -> Result<ContextState, A2AError> {
1849        let context_id = context_id.as_str();
1850        // The same claim-then-check the conversation gets: a context id that
1851        // reads back what was remembered in it is a capability, and this store
1852        // is reached on the same turn as `load`.
1853        self.claim_or_check_context(context_id, caller).await?;
1854
1855        // Both scopes in one round trip. With no principal the second parameter
1856        // binds NULL, and `scope_key = NULL` matches nothing — which is the
1857        // right answer, since a `user:` key cannot have been written without
1858        // one.
1859        let sql = self.sql(
1860            "SELECT scope, name, value FROM context_state \
1861             WHERE (scope = 'context' AND scope_key = ?) \
1862                OR (scope = 'user' AND scope_key = ?)",
1863        );
1864        let rows = sqlx::query(&sql)
1865            .bind(context_id)
1866            .bind(caller)
1867            .fetch_all(&self.pool)
1868            .await
1869            .map_err(|e| A2AError::DatabaseError(format!("Failed to load context state: {}", e)))?;
1870
1871        let mut state = ContextState::new();
1872        for row in rows {
1873            let scope: String = row.try_get("scope").map_err(|e| {
1874                A2AError::DatabaseError(format!("Failed to get state scope: {}", e))
1875            })?;
1876            let name: String = row
1877                .try_get("name")
1878                .map_err(|e| A2AError::DatabaseError(format!("Failed to get state key: {}", e)))?;
1879            let value: String = row.try_get("value").map_err(|e| {
1880                A2AError::DatabaseError(format!("Failed to get state value: {}", e))
1881            })?;
1882
1883            let scope = match scope.as_str() {
1884                "user" => StateScope::User,
1885                "context" => StateScope::Context,
1886                // A scope this build does not know. Skipped rather than guessed
1887                // at: filing it under the wrong scope would report a lifetime
1888                // the row does not have.
1889                _other => {
1890                    #[cfg(feature = "tracing")]
1891                    tracing::warn!("ignoring state row with unknown scope '{_other}'");
1892                    continue;
1893                }
1894            };
1895            match StateKey::scoped(scope, &name) {
1896                Ok(key) => state.insert(key, value),
1897                Err(_e) => {
1898                    #[cfg(feature = "tracing")]
1899                    tracing::warn!("ignoring unusable state key '{name}': {_e}");
1900                }
1901            }
1902        }
1903
1904        if let Some(caller) = caller {
1905            self.refresh_user_state(caller).await?;
1906        }
1907        Ok(state)
1908    }
1909
1910    async fn remember(
1911        &self,
1912        context_id: &ContextId,
1913        caller: Option<&str>,
1914        key: &StateKey,
1915        value: &str,
1916    ) -> Result<Remembered, A2AError> {
1917        let context_id = context_id.as_str();
1918        self.claim_or_check_context(context_id, caller).await?;
1919
1920        let (Some(scope_key), Some(scope)) = (
1921            scope_key(key.scope(), context_id, caller, key)?,
1922            scope_column(key.scope()),
1923        ) else {
1924            return Ok(Remembered::NotStored);
1925        };
1926
1927        // Read and write in one transaction. Without it a concurrent write
1928        // landing between the two makes `previous` name a value this call never
1929        // overwrote — a report about somebody else's write, wearing ours.
1930        //
1931        // Two statements rather than one because neither dialect will hand back
1932        // the old row: PostgreSQL 17's `RETURNING OLD.value` is too new to
1933        // require, and the `WITH prev AS (…) … RETURNING (SELECT …)` form it
1934        // would take on PostgreSQL has no SQLite spelling — `RETURNING` there
1935        // can only name the row that was modified.
1936        let mut tx = self.pool.begin().await.map_err(|e| {
1937            A2AError::DatabaseError(format!("Failed to open a state transaction: {}", e))
1938        })?;
1939
1940        let read = self
1941            .sql("SELECT value FROM context_state WHERE scope = ? AND scope_key = ? AND name = ?");
1942        let previous: Option<String> = sqlx::query(&read)
1943            .bind(scope)
1944            .bind(scope_key)
1945            .bind(key.name())
1946            .fetch_optional(&mut *tx)
1947            .await
1948            .map_err(|e| A2AError::DatabaseError(format!("Failed to read context state: {}", e)))?
1949            .map(|row| row.try_get("value"))
1950            .transpose()
1951            .map_err(|e| A2AError::DatabaseError(format!("Failed to read context state: {}", e)))?;
1952
1953        sqlx::query(self.dialect.upsert_context_state())
1954            .bind(scope)
1955            .bind(scope_key)
1956            .bind(key.name())
1957            .bind(value)
1958            .execute(&mut *tx)
1959            .await
1960            .map_err(|e| {
1961                A2AError::DatabaseError(format!("Failed to write context state: {}", e))
1962            })?;
1963
1964        tx.commit().await.map_err(|e| {
1965            A2AError::DatabaseError(format!("Failed to commit context state: {}", e))
1966        })?;
1967
1968        Ok(match previous {
1969            None => Remembered::Stored,
1970            Some(previous) if previous == value => Remembered::Unchanged,
1971            Some(previous) => Remembered::Replaced { previous },
1972        })
1973    }
1974
1975    async fn forget(
1976        &self,
1977        context_id: &ContextId,
1978        caller: Option<&str>,
1979        key: &StateKey,
1980    ) -> Result<bool, A2AError> {
1981        let context_id = context_id.as_str();
1982        self.claim_or_check_context(context_id, caller).await?;
1983
1984        let (Some(scope_key), Some(scope)) = (
1985            scope_key(key.scope(), context_id, caller, key)?,
1986            scope_column(key.scope()),
1987        ) else {
1988            return Ok(false);
1989        };
1990
1991        let sql =
1992            self.sql("DELETE FROM context_state WHERE scope = ? AND scope_key = ? AND name = ?");
1993        let deleted = sqlx::query(&sql)
1994            .bind(scope)
1995            .bind(scope_key)
1996            .bind(key.name())
1997            .execute(&self.pool)
1998            .await
1999            .map_err(|e| A2AError::DatabaseError(format!("Failed to drop context state: {}", e)))?;
2000        Ok(deleted.rows_affected() > 0)
2001    }
2002}
2003
2004/// How an [`UpdateEvent`] variant is spelled in `task_events.kind`.
2005///
2006/// Written out rather than derived from the payload so the column stays
2007/// readable, and so a variant added later has to name itself here rather than
2008/// round-tripping as whichever variant happened to deserialize.
2009#[cfg(feature = "sqlx-storage")]
2010const KIND_STATUS: &str = "status-update";
2011#[cfg(feature = "sqlx-storage")]
2012const KIND_ARTIFACT: &str = "artifact-update";
2013
2014#[cfg(feature = "sqlx-storage")]
2015#[async_trait]
2016impl AsyncEventLog for SqlxTaskStorage {
2017    async fn append(&self, task_id: &str, event: UpdateEvent) -> Result<SeqEvent, A2AError> {
2018        let (kind, payload) = match &event {
2019            UpdateEvent::StatusUpdate(update) => (KIND_STATUS, serde_json::to_string(update)),
2020            UpdateEvent::ArtifactUpdate(update) => (KIND_ARTIFACT, serde_json::to_string(update)),
2021        };
2022        let payload = payload.map_err(|e| {
2023            A2AError::DatabaseError(format!("Failed to serialize a stream event: {e}"))
2024        })?;
2025
2026        let sql = self.dialect.insert_task_event();
2027        let id: i64 = sqlx::query(sql)
2028            .bind(task_id)
2029            .bind(kind)
2030            .bind(&payload)
2031            .bind(task_id)
2032            .fetch_one(&self.pool)
2033            .await
2034            .and_then(|row| row.try_get("id"))
2035            .map_err(|e| {
2036                A2AError::DatabaseError(format!("Failed to log a stream event for {task_id}: {e}"))
2037            })?;
2038        let id = id as u64;
2039
2040        if let Some(capacity) = self.event_log_capacity
2041            && let Some(cutoff) = id.checked_sub(capacity)
2042        {
2043            let sql = self.sql("DELETE FROM task_events WHERE task_id = ? AND id <= ?");
2044            sqlx::query(&sql)
2045                .bind(task_id)
2046                .bind(cutoff as i64)
2047                .execute(&self.pool)
2048                .await
2049                .map_err(|e| {
2050                    A2AError::DatabaseError(format!(
2051                        "Failed to trim the stream log for {task_id}: {e}"
2052                    ))
2053                })?;
2054        }
2055
2056        Ok(SeqEvent::new(id, event))
2057    }
2058
2059    async fn replay(&self, task_id: &str, from: u64) -> Result<Replay, A2AError> {
2060        // The oldest retained id is what says whether the tail below is the
2061        // remainder of the client's gap or a fragment of it, so it is read in
2062        // the same breath rather than inferred from the rows that come back.
2063        let sql = self.sql("SELECT MIN(id) AS oldest FROM task_events WHERE task_id = ?");
2064        let oldest: Option<i64> = sqlx::query(&sql)
2065            .bind(task_id)
2066            .fetch_one(&self.pool)
2067            .await
2068            .and_then(|row| row.try_get("oldest"))
2069            .map_err(|e| {
2070                A2AError::DatabaseError(format!("Failed to read the stream log for {task_id}: {e}"))
2071            })?;
2072
2073        let sql = self.sql(
2074            "SELECT id, kind, payload FROM task_events \
2075             WHERE task_id = ? AND id > ? ORDER BY id",
2076        );
2077        let rows = sqlx::query(&sql)
2078            .bind(task_id)
2079            .bind(from as i64)
2080            .fetch_all(&self.pool)
2081            .await
2082            .map_err(|e| {
2083                A2AError::DatabaseError(format!("Failed to replay the log for {task_id}: {e}"))
2084            })?;
2085
2086        let events = rows
2087            .iter()
2088            .map(Self::row_to_seq_event)
2089            .collect::<Result<Vec<_>, _>>()?;
2090
2091        Ok(Replay::bounded_by(
2092            oldest.map(|oldest| oldest as u64),
2093            from,
2094            events,
2095        ))
2096    }
2097
2098    async fn discard(&self, task_id: &str) -> Result<(), A2AError> {
2099        let sql = self.sql("DELETE FROM task_events WHERE task_id = ?");
2100        sqlx::query(&sql)
2101            .bind(task_id)
2102            .execute(&self.pool)
2103            .await
2104            .map_err(|e| {
2105                A2AError::DatabaseError(format!(
2106                    "Failed to discard the stream log for {task_id}: {e}"
2107                ))
2108            })?;
2109        Ok(())
2110    }
2111}
2112
2113#[cfg(feature = "sqlx-storage")]
2114impl SqlxTaskStorage {
2115    /// Rebuild one logged event from its row.
2116    fn row_to_seq_event(row: &sqlx::any::AnyRow) -> Result<SeqEvent, A2AError> {
2117        let read = |column: &str| -> Result<String, A2AError> {
2118            row.try_get(column).map_err(|e| {
2119                A2AError::DatabaseError(format!("Failed to read stream event {column}: {e}"))
2120            })
2121        };
2122        let id: i64 = row
2123            .try_get("id")
2124            .map_err(|e| A2AError::DatabaseError(format!("Failed to read stream event id: {e}")))?;
2125        let kind = read("kind")?;
2126        let payload = read("payload")?;
2127
2128        fn parse(what: &'static str) -> impl Fn(serde_json::Error) -> A2AError {
2129            move |e| A2AError::DatabaseError(format!("Failed to parse a logged {what}: {e}"))
2130        }
2131        let event = match kind.as_str() {
2132            KIND_STATUS => UpdateEvent::StatusUpdate(
2133                serde_json::from_str(&payload).map_err(parse("status update"))?,
2134            ),
2135            KIND_ARTIFACT => UpdateEvent::ArtifactUpdate(
2136                serde_json::from_str(&payload).map_err(parse("artifact update"))?,
2137            ),
2138            other => {
2139                return Err(A2AError::DatabaseError(format!(
2140                    "Unknown stream event kind {other:?} in task_events"
2141                )));
2142            }
2143        };
2144
2145        Ok(SeqEvent::new(id as u64, event))
2146    }
2147}
2148
2149#[cfg(feature = "sqlx-storage")]
2150#[async_trait]
2151impl AsyncRetention for SqlxTaskStorage {
2152    async fn sweep(
2153        &self,
2154        policy: &RetentionPolicy,
2155        now: chrono::DateTime<chrono::Utc>,
2156    ) -> Result<Swept, A2AError> {
2157        let mut swept = Swept::default();
2158
2159        if let Some(cutoff) = policy.context_cutoff(now) {
2160            for context_id in self.idle_contexts(cutoff).await? {
2161                swept += self.delete_context(&context_id).await?;
2162            }
2163        }
2164
2165        if let Some(cutoff) = policy.user_state_cutoff(now) {
2166            for principal in self.idle_principals(cutoff).await? {
2167                swept.state_keys += self.delete_user_state(&principal).await?;
2168            }
2169        }
2170
2171        Ok(swept)
2172    }
2173}
2174
2175#[cfg(feature = "sqlx-storage")]
2176impl SqlxTaskStorage {
2177    /// Contexts nothing has written to since `cutoff`, skipping any that still
2178    /// hold an unfinished task.
2179    async fn idle_contexts(
2180        &self,
2181        cutoff: chrono::DateTime<chrono::Utc>,
2182    ) -> Result<Vec<String>, A2AError> {
2183        let rows = sqlx::query(self.dialect.idle_contexts())
2184            .bind(self.dialect.format_timestamp(cutoff))
2185            .fetch_all(&self.pool)
2186            .await
2187            .map_err(|e| A2AError::DatabaseError(format!("Failed to find idle contexts: {e}")))?;
2188
2189        rows.iter()
2190            .map(|row| {
2191                row.try_get("ctx").map_err(|e| {
2192                    A2AError::DatabaseError(format!("Failed to read idle context id: {e}"))
2193                })
2194            })
2195            .collect()
2196    }
2197
2198    /// Principals whose `user:`-scoped state has not been written since `cutoff`.
2199    async fn idle_principals(
2200        &self,
2201        cutoff: chrono::DateTime<chrono::Utc>,
2202    ) -> Result<Vec<String>, A2AError> {
2203        let rows = sqlx::query(self.dialect.idle_principals())
2204            .bind(self.dialect.format_timestamp(cutoff))
2205            .fetch_all(&self.pool)
2206            .await
2207            .map_err(|e| A2AError::DatabaseError(format!("Failed to find idle principals: {e}")))?;
2208
2209        rows.iter()
2210            .map(|row| {
2211                row.try_get("scope_key").map_err(|e| {
2212                    A2AError::DatabaseError(format!("Failed to read idle principal: {e}"))
2213                })
2214            })
2215            .collect()
2216    }
2217
2218    /// Delete one context and everything filed under it, in one transaction.
2219    ///
2220    /// Every table is named explicitly rather than left to the `ON DELETE
2221    /// CASCADE` clauses in the schema. The cascades do fire on both backends —
2222    /// PostgreSQL enforces its constraints unasked, and `pool_options` turns
2223    /// SQLite's `foreign_keys` pragma on — but only some of what a sweep
2224    /// deletes is reachable through them: `context_state` has no foreign key at
2225    /// all, since half its rows are keyed by principal rather than by context.
2226    /// The per-table counts in [`Swept`] need the statements anyway.
2227    ///
2228    /// Order follows the references: push configs, history and stream events
2229    /// name a task, so they go before `tasks`.
2230    async fn delete_context(&self, context_id: &str) -> Result<Swept, A2AError> {
2231        let mut tx = self.pool.begin().await.map_err(|e| {
2232            A2AError::DatabaseError(format!("Failed to open a sweep transaction: {e}"))
2233        })?;
2234
2235        let fail = |table: &str, e: sqlx::Error| {
2236            A2AError::DatabaseError(format!("Failed to sweep {table} for {context_id}: {e}"))
2237        };
2238
2239        let sql = self.sql(
2240            "DELETE FROM push_notification_configs              WHERE task_id IN (SELECT id FROM tasks WHERE context_id = ?)",
2241        );
2242        sqlx::query(&sql)
2243            .bind(context_id)
2244            .execute(&mut *tx)
2245            .await
2246            .map_err(|e| fail("push configs", e))?;
2247
2248        // `task_history` holds status transitions as well as messages, and only
2249        // the rows carrying a message are the conversation — the same
2250        // `message IS NOT NULL` that `load` reads by. Counted before the delete
2251        // so `Swept::messages` means the same quantity in both adapters, where
2252        // `rows_affected` would report a number the in-memory store has no
2253        // equivalent of.
2254        let sql = self.sql(
2255            "SELECT COUNT(*) AS count FROM task_history              WHERE message IS NOT NULL AND (context_id = ?              OR task_id IN (SELECT id FROM tasks WHERE context_id = ?))",
2256        );
2257        let messages: i64 = sqlx::query(&sql)
2258            .bind(context_id)
2259            .bind(context_id)
2260            .fetch_one(&mut *tx)
2261            .await
2262            .and_then(|row| row.try_get("count"))
2263            .map_err(|e| fail("history", e))?;
2264
2265        // The `OR` catches history written before migration 004 added
2266        // `context_id`, which the backfill only reaches for rows whose task
2267        // still exists.
2268        let sql = self.sql(
2269            "DELETE FROM task_history WHERE context_id = ?              OR task_id IN (SELECT id FROM tasks WHERE context_id = ?)",
2270        );
2271        sqlx::query(&sql)
2272            .bind(context_id)
2273            .bind(context_id)
2274            .execute(&mut *tx)
2275            .await
2276            .map_err(|e| fail("history", e))?;
2277
2278        // Before `tasks`, which is what names them: `task_events` has no foreign
2279        // key, so nothing deletes these once the task rows are gone.
2280        let sql = self.sql(
2281            "DELETE FROM task_events              WHERE task_id IN (SELECT id FROM tasks WHERE context_id = ?)",
2282        );
2283        sqlx::query(&sql)
2284            .bind(context_id)
2285            .execute(&mut *tx)
2286            .await
2287            .map_err(|e| fail("stream events", e))?;
2288
2289        let sql = self.sql("DELETE FROM tasks WHERE context_id = ?");
2290        let tasks = sqlx::query(&sql)
2291            .bind(context_id)
2292            .execute(&mut *tx)
2293            .await
2294            .map_err(|e| fail("tasks", e))?;
2295
2296        let sql = self.sql("DELETE FROM context_digests WHERE context_id = ?");
2297        let digests = sqlx::query(&sql)
2298            .bind(context_id)
2299            .execute(&mut *tx)
2300            .await
2301            .map_err(|e| fail("digests", e))?;
2302
2303        let sql = self.sql("DELETE FROM context_state WHERE scope = 'context' AND scope_key = ?");
2304        let state_keys = sqlx::query(&sql)
2305            .bind(context_id)
2306            .execute(&mut *tx)
2307            .await
2308            .map_err(|e| fail("state", e))?;
2309
2310        let sql = self.sql("DELETE FROM contexts WHERE id = ?");
2311        sqlx::query(&sql)
2312            .bind(context_id)
2313            .execute(&mut *tx)
2314            .await
2315            .map_err(|e| fail("the context row", e))?;
2316
2317        tx.commit().await.map_err(|e| {
2318            A2AError::DatabaseError(format!("Failed to commit the sweep of {context_id}: {e}"))
2319        })?;
2320
2321        // After the commit, not before: a sweep that rolls back must not leave
2322        // this process re-reading an answer it still holds. Deletion is the only
2323        // thing that can invalidate a cached claim, so this is the only eviction
2324        // there is.
2325        if let Some(cache) = self.claim_cache.as_ref() {
2326            cache.forget(context_id);
2327        }
2328
2329        Ok(Swept {
2330            contexts: 1,
2331            tasks: tasks.rows_affected(),
2332            messages: messages.max(0) as u64,
2333            digests: digests.rows_affected(),
2334            state_keys: state_keys.rows_affected(),
2335        })
2336    }
2337
2338    /// Delete every `user:`-scoped key one principal holds, returning how many.
2339    async fn delete_user_state(&self, principal: &str) -> Result<u64, A2AError> {
2340        let sql = self.sql("DELETE FROM context_state WHERE scope = 'user' AND scope_key = ?");
2341        let deleted = sqlx::query(&sql)
2342            .bind(principal)
2343            .execute(&self.pool)
2344            .await
2345            .map_err(|e| {
2346                A2AError::DatabaseError(format!("Failed to sweep user state for {principal}: {e}"))
2347            })?;
2348        Ok(deleted.rows_affected())
2349    }
2350}
2351
2352#[cfg(all(test, feature = "sqlx-storage"))]
2353mod tests {
2354    use super::*;
2355
2356    /// Migration 006 drops `contexts.state`, which 005 created and nothing ever
2357    /// wrote. A database made by an older build still has it, and this is the
2358    /// path that clears it — on SQLite, where `ALTER TABLE … DROP COLUMN` has
2359    /// the most conditions attached and the drop is deliberately best effort.
2360    ///
2361    /// Inside the adapter rather than in `tests/`, because putting the column
2362    /// back needs the pool.
2363    #[tokio::test]
2364    async fn the_dead_state_column_is_dropped_on_the_next_start() {
2365        let dir = tempfile::tempdir().unwrap();
2366        let url = format!("sqlite:{}?mode=rwc", dir.path().join("a2a.db").display());
2367
2368        let storage = SqlxTaskStorage::new(&url).await.unwrap();
2369        sqlx::raw_sql("ALTER TABLE contexts ADD COLUMN state TEXT NOT NULL DEFAULT '{}'")
2370            .execute(&storage.pool)
2371            .await
2372            .expect("put the pre-006 column back");
2373        assert!(has_state_column(&storage).await);
2374        drop(storage);
2375
2376        let restarted = SqlxTaskStorage::new(&url).await.unwrap();
2377        assert!(
2378            !has_state_column(&restarted).await,
2379            "the unused column should be gone after the migration runs"
2380        );
2381    }
2382
2383    /// The schema's `ON DELETE CASCADE` clauses are only worth anything while
2384    /// `foreign_keys` is on, and it is a per-connection pragma that SQLite
2385    /// itself defaults to off. Asserted against a pooled connection rather than
2386    /// a fresh one, since the pool is the only way anything here reaches the
2387    /// database.
2388    #[tokio::test]
2389    async fn sqlite_connections_enforce_foreign_keys() {
2390        let storage = SqlxTaskStorage::new("sqlite::memory:").await.unwrap();
2391
2392        let on: i64 = sqlx::query("PRAGMA foreign_keys")
2393            .fetch_one(&storage.pool)
2394            .await
2395            .unwrap()
2396            .try_get(0)
2397            .unwrap();
2398        assert_eq!(on, 1);
2399    }
2400
2401    /// The `Any` driver parses URLs itself and rejects SQLite's own parameters,
2402    /// so `foreign_keys` cannot be set — either way — through the one knob a
2403    /// caller of this adapter has. That is what leaves `after_connect` as the
2404    /// only place this crate can state it.
2405    #[tokio::test]
2406    async fn the_url_cannot_speak_for_foreign_keys() {
2407        let dir = tempfile::tempdir().unwrap();
2408        let url = format!(
2409            "sqlite:{}?mode=rwc&foreign_keys=off",
2410            dir.path().join("a2a.db").display()
2411        );
2412
2413        let refused = SqlxTaskStorage::new(&url).await;
2414        assert!(
2415            refused.is_err(),
2416            "a SQLite-specific URL parameter should not reach the driver"
2417        );
2418    }
2419
2420    /// What the pragma actually buys: deleting a task takes its history with it.
2421    /// This is the behaviour the retention sweep deliberately does not rely on,
2422    /// and it is worth knowing which of the two is true.
2423    #[tokio::test]
2424    async fn deleting_a_task_cascades_to_its_history() {
2425        let storage = SqlxTaskStorage::new("sqlite::memory:").await.unwrap();
2426        let (task, context) = (tid("task-cascade"), cid("ctx-cascade"));
2427
2428        storage.create(&task, &context).await.unwrap();
2429        storage
2430            .update_status(&task, TaskState::Working, None)
2431            .await
2432            .unwrap();
2433        assert!(history_rows(&storage, "task-cascade").await > 0);
2434
2435        sqlx::query("DELETE FROM tasks WHERE id = ?")
2436            .bind("task-cascade")
2437            .execute(&storage.pool)
2438            .await
2439            .unwrap();
2440
2441        assert_eq!(
2442            history_rows(&storage, "task-cascade").await,
2443            0,
2444            "history should go with the task it references"
2445        );
2446    }
2447
2448    async fn history_rows(storage: &SqlxTaskStorage, task_id: &str) -> i64 {
2449        sqlx::query("SELECT COUNT(*) AS count FROM task_history WHERE task_id = ?")
2450            .bind(task_id)
2451            .fetch_one(&storage.pool)
2452            .await
2453            .unwrap()
2454            .try_get("count")
2455            .unwrap()
2456    }
2457
2458    fn tid(s: &str) -> TaskId {
2459        s.parse().unwrap()
2460    }
2461    fn cid(s: &str) -> ContextId {
2462        s.parse().unwrap()
2463    }
2464
2465    async fn has_state_column(storage: &SqlxTaskStorage) -> bool {
2466        sqlx::query(storage.dialect.dead_context_state_column_probe())
2467            .fetch_optional(&storage.pool)
2468            .await
2469            .unwrap()
2470            .is_some()
2471    }
2472
2473    /// A hit inside the window, a miss outside it. A zero window is what makes
2474    /// this assertable without sleeping: nothing is ever new enough.
2475    #[test]
2476    fn a_cached_claim_is_only_returned_inside_its_window() {
2477        let cache = ClaimCache::new(Duration::from_secs(60));
2478        cache.insert("ctx-1", &ContextClaim::Owner("alice".to_string()));
2479        assert!(matches!(
2480            cache.get("ctx-1"),
2481            Some(ContextClaim::Owner(owner)) if owner == "alice"
2482        ));
2483
2484        let expired = ClaimCache::new(Duration::ZERO);
2485        expired.insert("ctx-1", &ContextClaim::Open);
2486        assert!(expired.get("ctx-1").is_none());
2487    }
2488
2489    #[test]
2490    fn forgetting_a_context_drops_what_was_cached_for_it() {
2491        let cache = ClaimCache::new(Duration::from_secs(60));
2492        cache.insert("ctx-1", &ContextClaim::Open);
2493        cache.forget("ctx-1");
2494        assert!(cache.get("ctx-1").is_none());
2495    }
2496
2497    /// Full means start over, which is a correctness-free choice: every entry
2498    /// is reconstructible with one `SELECT`.
2499    #[test]
2500    fn a_full_cache_starts_over_rather_than_growing() {
2501        let cache = ClaimCache::new(Duration::from_secs(60));
2502        for i in 0..=CLAIM_CACHE_CAPACITY {
2503            cache.insert(&format!("ctx-{i}"), &ContextClaim::Open);
2504        }
2505        assert!(cache.entries.lock().unwrap().len() <= CLAIM_CACHE_CAPACITY);
2506    }
2507
2508    /// The cache is actually consulted, shown the only way that cannot be
2509    /// mistaken for something else: change the row underneath it.
2510    ///
2511    /// This is also the staleness window written down as a test. Nothing in the
2512    /// store can reassign an owner — that is what makes caching the answer
2513    /// sound — so the tampering here stands in for another replica sweeping the
2514    /// context and a different principal claiming the id afresh.
2515    #[tokio::test]
2516    async fn a_cached_owner_outlives_a_row_changed_behind_the_store() {
2517        let dir = tempfile::tempdir().unwrap();
2518        let url = format!("sqlite:{}?mode=rwc", dir.path().join("a2a.db").display());
2519        let storage = SqlxTaskStorage::builder(&url)
2520            .max_connections(1)
2521            .claim_cache(Some(Duration::from_secs(60)))
2522            .connect()
2523            .await
2524            .unwrap();
2525
2526        let context = cid("ctx-cached");
2527        storage.load_state(&context, Some("alice")).await.unwrap();
2528
2529        sqlx::raw_sql("UPDATE contexts SET owner = 'bob' WHERE id = 'ctx-cached'")
2530            .execute(&storage.pool)
2531            .await
2532            .unwrap();
2533
2534        storage
2535            .load_state(&context, Some("alice"))
2536            .await
2537            .expect("the claim alice opened is still the cached one");
2538
2539        // Closed before the second store opens: two pools writing one SQLite
2540        // file is a lock fight this test has no reason to pick.
2541        drop(storage);
2542
2543        let uncached = SqlxTaskStorage::builder(&url)
2544            .max_connections(1)
2545            .claim_cache(None)
2546            .connect()
2547            .await
2548            .unwrap();
2549        assert!(
2550            matches!(
2551                uncached.load_state(&context, Some("alice")).await,
2552                Err(A2AError::ContextAccessDenied { .. })
2553            ),
2554            "a store that reads every time sees the row as it now is"
2555        );
2556    }
2557
2558    /// The other half of `ReadRefresh`, which needs a bag older than the window
2559    /// and no port lets a caller write one. Ageing the row directly is the only
2560    /// way, and having the pool is why this lives here rather than in
2561    /// `tests/context_state_test.rs`.
2562    ///
2563    /// Asserted through a sweep rather than by reading `updated_at` back: the
2564    /// `Any` driver cannot decode a `timestamptz`, which is the same constraint
2565    /// that makes the refresh write blind in the first place. A sweep at the
2566    /// real `now` is the store's own answer to "how old is this bag".
2567    #[tokio::test]
2568    async fn a_read_refreshes_a_bag_that_is_old_enough() {
2569        let day = Duration::from_secs(24 * 60 * 60);
2570        let storage = SqlxTaskStorage::builder("sqlite::memory:")
2571            .max_connections(1)
2572            .read_refresh(ReadRefresh::after(day))
2573            .connect()
2574            .await
2575            .unwrap();
2576
2577        let context = cid("ctx-refresh");
2578        let name = StateKey::scoped(StateScope::User, "name").unwrap();
2579        storage
2580            .remember(&context, Some("alice"), &name, "Emil")
2581            .await
2582            .unwrap();
2583        age_the_bag(&storage, "alice").await;
2584
2585        storage.load_state(&context, Some("alice")).await.unwrap();
2586
2587        let policy = RetentionPolicy::keep_everything().delete_user_state_idle_for(day);
2588        let swept = storage.sweep(&policy, chrono::Utc::now()).await.unwrap();
2589        assert_eq!(
2590            swept.state_keys, 0,
2591            "the read moved the bag out of the sweep's reach"
2592        );
2593    }
2594
2595    /// The same bag, the same read, and no refresh configured: it is swept,
2596    /// which is what makes the case above about the refresh and not about the
2597    /// sweep.
2598    #[tokio::test]
2599    async fn without_a_refresh_the_same_read_leaves_the_bag_sweepable() {
2600        let day = Duration::from_secs(24 * 60 * 60);
2601        let storage = SqlxTaskStorage::builder("sqlite::memory:")
2602            .max_connections(1)
2603            .connect()
2604            .await
2605            .unwrap();
2606
2607        let context = cid("ctx-no-refresh");
2608        let name = StateKey::scoped(StateScope::User, "name").unwrap();
2609        storage
2610            .remember(&context, Some("alice"), &name, "Emil")
2611            .await
2612            .unwrap();
2613        age_the_bag(&storage, "alice").await;
2614
2615        storage.load_state(&context, Some("alice")).await.unwrap();
2616
2617        let policy = RetentionPolicy::keep_everything().delete_user_state_idle_for(day);
2618        let swept = storage.sweep(&policy, chrono::Utc::now()).await.unwrap();
2619        assert_eq!(swept.state_keys, 1);
2620    }
2621
2622    /// Put a principal's bag a week into the past, which is the one thing the
2623    /// port cannot do.
2624    async fn age_the_bag(storage: &SqlxTaskStorage, principal: &str) {
2625        let sql = storage
2626            .sql("UPDATE context_state SET updated_at = ? WHERE scope = 'user' AND scope_key = ?");
2627        let long_ago = storage
2628            .dialect
2629            .format_timestamp(chrono::Utc::now() - chrono::TimeDelta::days(7));
2630        sqlx::query(&sql)
2631            .bind(long_ago)
2632            .bind(principal)
2633            .execute(&storage.pool)
2634            .await
2635            .unwrap();
2636    }
2637
2638    /// The one thing that can invalidate a cached claim is deletion, so the
2639    /// sweep has to evict — otherwise a swept id stays owned by a principal
2640    /// whose row is gone, and the caller who re-opens it is refused their own
2641    /// context.
2642    #[tokio::test]
2643    async fn a_swept_context_leaves_nothing_cached() {
2644        let storage = SqlxTaskStorage::builder("sqlite::memory:")
2645            .max_connections(1)
2646            .claim_cache(Some(Duration::from_secs(60)))
2647            .connect()
2648            .await
2649            .unwrap();
2650
2651        let context = cid("ctx-swept");
2652        storage.load_state(&context, Some("alice")).await.unwrap();
2653
2654        let policy =
2655            RetentionPolicy::keep_everything().delete_contexts_idle_for(Duration::from_secs(60));
2656        let swept = storage
2657            .sweep(&policy, chrono::Utc::now() + chrono::TimeDelta::days(30))
2658            .await
2659            .unwrap();
2660        assert_eq!(swept.contexts, 1);
2661
2662        storage
2663            .load_state(&context, Some("bob"))
2664            .await
2665            .expect("nobody holds a context that was swept");
2666    }
2667}