Skip to main content

tact_memory/store/remote/
client.rs

1use crate::{
2    MemoryCandidate, MemoryError, MemoryKey, MemoryLimits, MemoryRecord, MemoryScan, MemoryStore,
3    server::protocol,
4};
5use protocol::{
6    DeleteRequest, ErrorResponse, ExportRequest, ExportResponse, ListResponse, PutRequest,
7    PutResponse, ReadRequest, ReadResponse, RemoteErrorCode, RemoteRole, ScanRequest, ScanResponse,
8    SessionResponse, SyncReport, SyncRequest,
9};
10use reqwest::{Client, Response, StatusCode, Url};
11use serde::{Serialize, de::DeserializeOwned};
12use std::{collections::HashSet, fmt, sync::Arc, time::Duration};
13use thiserror::Error;
14use tokio::time::sleep;
15use zeroize::{Zeroize, Zeroizing};
16
17const ATTEMPTS: usize = 3;
18const CONNECT_TIMEOUT: Duration = Duration::from_millis(750);
19const REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
20const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
21const RETRY_BACKOFFS: [Option<Duration>; ATTEMPTS] = [
22    Some(Duration::from_millis(100)),
23    Some(Duration::from_millis(250)),
24    None,
25];
26
27/// Secret bearer token for a remote memory service.
28pub struct RemoteToken(Zeroizing<String>);
29
30impl RemoteToken {
31    /// Wraps a non-empty token in zeroizing storage.
32    pub fn new(token: String) -> Result<Self, RemoteClientError> {
33        if token.trim().is_empty() {
34            return Err(RemoteClientError::EmptyToken);
35        }
36        Ok(Self(Zeroizing::new(token)))
37    }
38
39    fn expose(&self) -> &str {
40        self.0.as_str()
41    }
42}
43
44impl fmt::Debug for RemoteToken {
45    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46        formatter.write_str("RemoteToken([REDACTED])")
47    }
48}
49
50impl Drop for RemoteToken {
51    fn drop(&mut self) {
52        self.zeroize();
53    }
54}
55
56impl Zeroize for RemoteToken {
57    fn zeroize(&mut self) {
58        self.0.zeroize();
59    }
60}
61
62/// Failure while configuring or calling a remote memory service.
63#[derive(Debug, Error)]
64pub enum RemoteClientError {
65    /// Endpoint is not an HTTP(S) URL without embedded credentials.
66    #[error("remote memory endpoint is invalid")]
67    InvalidEndpoint,
68    /// Namespace violates the wire-format contract.
69    #[error("remote memory namespace is invalid")]
70    InvalidNamespace,
71    /// Bearer token is empty.
72    #[error("remote memory token is empty")]
73    EmptyToken,
74    /// Request failed before a valid response arrived.
75    #[error("remote memory request could not reach the server")]
76    Transport,
77    /// Server rejected the bearer token.
78    #[error("remote memory server rejected authentication")]
79    Unauthorized,
80    /// Credential cannot mutate its namespace.
81    #[error("remote memory credential is read-only")]
82    ReadOnly,
83    /// Server returned a different authenticated namespace.
84    #[error("remote memory namespace does not match the credential")]
85    NamespaceMismatch,
86    /// Server speaks a different protocol version.
87    #[error("remote memory protocol is incompatible")]
88    IncompatibleProtocol,
89    /// Server rejected the request with a protocol error code.
90    #[error("remote memory server rejected the operation: {code:?}")]
91    Rejected {
92        /// Stable server failure category.
93        code: RemoteErrorCode,
94    },
95    /// Response violated bounds, ordering, or ownership constraints.
96    #[error("remote memory server returned an invalid response")]
97    InvalidResponse,
98    /// Service remained unavailable after bounded retries.
99    #[error("remote memory service is unavailable")]
100    Unavailable,
101}
102
103/// Authenticated HTTP implementation of [`MemoryStore`].
104#[derive(Clone)]
105pub struct RemoteMemoryClient {
106    inner: Arc<RemoteClientInner>,
107}
108
109impl fmt::Debug for RemoteMemoryClient {
110    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
111        formatter
112            .debug_struct("RemoteMemoryClient")
113            .field("endpoint", &self.inner.endpoint)
114            .field("namespace", &self.inner.namespace)
115            .field("token", &"[REDACTED]")
116            .finish()
117    }
118}
119
120struct RemoteClientInner {
121    endpoint: Url,
122    namespace: String,
123    token: RemoteToken,
124    client: Client,
125    role: tokio::sync::OnceCell<RemoteRole>,
126}
127
128impl RemoteMemoryClient {
129    /// Creates a client bound to one validated namespace.
130    pub fn new(
131        endpoint: &str,
132        namespace: String,
133        token: RemoteToken,
134    ) -> Result<Self, RemoteClientError> {
135        let _ = rustls::crypto::ring::default_provider().install_default();
136        let mut endpoint = Url::parse(endpoint).map_err(|_| RemoteClientError::InvalidEndpoint)?;
137        if !matches!(endpoint.scheme(), "http" | "https")
138            || !endpoint.username().is_empty()
139            || endpoint.password().is_some()
140        {
141            return Err(RemoteClientError::InvalidEndpoint);
142        }
143        if !protocol::is_valid_namespace(&namespace) {
144            return Err(RemoteClientError::InvalidNamespace);
145        }
146        if !endpoint.path().ends_with('/') {
147            let mut path = endpoint.path().to_owned();
148            path.push('/');
149            endpoint.set_path(&path);
150        }
151        let client = Client::builder()
152            .connect_timeout(CONNECT_TIMEOUT)
153            .timeout(REQUEST_TIMEOUT)
154            .build()
155            .map_err(|_| RemoteClientError::InvalidEndpoint)?;
156        Ok(Self {
157            inner: Arc::new(RemoteClientInner {
158                endpoint,
159                namespace,
160                token,
161                client,
162                role: tokio::sync::OnceCell::new(),
163            }),
164        })
165    }
166
167    /// Returns the configured namespace without network I/O.
168    pub fn namespace(&self) -> &str {
169        &self.inner.namespace
170    }
171
172    /// Negotiates and caches the authenticated remote role.
173    pub async fn session(&self) -> Result<RemoteRole, RemoteClientError> {
174        self.inner
175            .role
176            .get_or_try_init(|| async {
177                let response: SessionResponse =
178                    self.get(protocol::SESSION_PATH, Replay::Safe).await?;
179                if response.protocol_version != crate::VERSION {
180                    return Err(RemoteClientError::IncompatibleProtocol);
181                }
182                if response.namespace != self.inner.namespace {
183                    return Err(RemoteClientError::NamespaceMismatch);
184                }
185                Ok(response.role)
186            })
187            .await
188            .copied()
189    }
190
191    async fn scan(
192        &self,
193        query: &str,
194        limit: usize,
195    ) -> Result<Vec<MemoryCandidate>, RemoteClientError> {
196        let limit = limit.min(MemoryLimits::PRODUCTION.scan_results);
197        if limit == 0 {
198            return Ok(Vec::new());
199        }
200        let response: ScanResponse = self
201            .post(
202                protocol::SCAN_PATH,
203                &ScanRequest {
204                    query: query.to_owned(),
205                    limit,
206                },
207                Replay::ConnectOnly,
208            )
209            .await?;
210        if response.candidates.len() > limit {
211            return Err(RemoteClientError::InvalidResponse);
212        }
213        let mut seen = HashSet::new();
214        let mut candidates = Vec::new();
215        let mut previous_score = None;
216        for candidate in response.candidates {
217            if !self.valid_candidate(&candidate) {
218                continue;
219            }
220            if previous_score.is_some_and(|score| candidate.score > score) {
221                return Err(RemoteClientError::InvalidResponse);
222            }
223            previous_score = Some(candidate.score);
224            let Some(namespace) = candidate.key.namespace.clone() else {
225                continue;
226            };
227            if !seen.insert((namespace, candidate.key.id)) {
228                return Err(RemoteClientError::InvalidResponse);
229            }
230            candidates.push(candidate);
231        }
232        Ok(candidates)
233    }
234
235    async fn read(
236        &self,
237        ids: &[i64],
238        keys: &[MemoryKey],
239    ) -> Result<Vec<MemoryRecord>, RemoteClientError> {
240        let keys = keys
241            .iter()
242            .filter(|key| Self::valid_key(key) && key.namespace.is_some())
243            .cloned()
244            .collect::<Vec<_>>();
245        let ids = ids.iter().copied().filter(|id| *id > 0).collect::<Vec<_>>();
246        if keys.is_empty() && ids.is_empty() {
247            return Ok(Vec::new());
248        }
249        let requested = keys.iter().cloned().collect::<HashSet<_>>();
250        let requested_ids = ids.iter().copied().collect::<HashSet<_>>();
251        let response: ReadResponse = self
252            .post(
253                protocol::READ_PATH,
254                &ReadRequest { ids, keys },
255                Replay::ConnectOnly,
256            )
257            .await?;
258        let requested_records = requested
259            .iter()
260            .filter_map(|key| key.namespace.clone().map(|namespace| (namespace, key.id)))
261            .chain(
262                requested_ids
263                    .iter()
264                    .map(|id| (self.namespace().to_owned(), *id)),
265            )
266            .collect::<HashSet<_>>();
267        if response.memories.len() > requested_records.len() {
268            return Err(RemoteClientError::InvalidResponse);
269        }
270
271        let mut seen = HashSet::new();
272        let mut memories = Vec::new();
273        for memory in response.memories {
274            if !(requested.contains(&memory.key)
275                || (memory.key.namespace.as_deref() == Some(self.namespace())
276                    && requested_ids.contains(&memory.key.id)))
277                || !Self::valid_record(&memory)
278            {
279                continue;
280            }
281            let logical_key = (memory.key.namespace.clone().unwrap(), memory.key.id);
282            if !seen.insert(logical_key) {
283                return Err(RemoteClientError::InvalidResponse);
284            }
285            memories.push(memory);
286        }
287        Ok(memories)
288    }
289
290    async fn list(&self) -> Result<Vec<MemoryRecord>, RemoteClientError> {
291        let response: ListResponse = self.post(protocol::LIST_PATH, &(), Replay::Safe).await?;
292        if response.memories.len() > MemoryLimits::PRODUCTION.records {
293            return Err(RemoteClientError::InvalidResponse);
294        }
295        let mut seen = HashSet::new();
296        let mut memories = Vec::new();
297        for memory in response.memories {
298            if !Self::valid_record(&memory) {
299                continue;
300            }
301            let Some(namespace) = memory.key.namespace.clone() else {
302                continue;
303            };
304            let logical_key = (namespace, memory.key.id);
305            if !seen.insert(logical_key) {
306                return Err(RemoteClientError::InvalidResponse);
307            }
308            memories.push(memory);
309        }
310        Ok(memories)
311    }
312
313    async fn put(
314        &self,
315        content: &str,
316        replacement: Option<&MemoryKey>,
317    ) -> Result<MemoryRecord, RemoteClientError> {
318        let response: PutResponse = self
319            .post(
320                protocol::PUT_PATH,
321                &PutRequest {
322                    content: content.to_owned(),
323                    replacement: replacement.cloned(),
324                },
325                Replay::ConnectOnly,
326            )
327            .await?;
328        if !Self::valid_record(&response.memory)
329            || response.memory.key.namespace.as_deref() != Some(self.namespace())
330            || response.memory.content != content
331            || match replacement {
332                Some(replacement) => {
333                    response.memory.key.id != replacement.id
334                        || replacement
335                            .version
336                            .checked_add(1)
337                            .is_none_or(|version| response.memory.key.version != version)
338                }
339                None => response.memory.key.version != 1,
340            }
341        {
342            return Err(RemoteClientError::InvalidResponse);
343        }
344        Ok(response.memory)
345    }
346
347    async fn delete(&self, key: &MemoryKey) -> Result<(), RemoteClientError> {
348        if key.namespace.as_deref() != Some(self.namespace()) {
349            return Err(RemoteClientError::NamespaceMismatch);
350        }
351        self.post::<_, serde_json::Value>(
352            protocol::DELETE_PATH,
353            &DeleteRequest { key: key.clone() },
354            Replay::Safe,
355        )
356        .await?;
357        Ok(())
358    }
359
360    async fn sync(&self, memories: &[MemoryRecord]) -> Result<SyncReport, RemoteClientError> {
361        let report: SyncReport = self
362            .post(
363                protocol::SYNC_PATH,
364                &SyncRequest {
365                    memories: memories.to_vec(),
366                },
367                Replay::Safe,
368            )
369            .await?;
370        let applied = report
371            .inserted
372            .checked_add(report.replaced)
373            .and_then(|count| count.checked_add(report.unchanged));
374        if applied != Some(memories.len()) || report.deleted > MemoryLimits::PRODUCTION.records {
375            return Err(RemoteClientError::InvalidResponse);
376        }
377        Ok(report)
378    }
379
380    fn validate_export_page(
381        namespaces: Option<&[String]>,
382        cursor: Option<&protocol::ExportCursor>,
383        accumulated_records: usize,
384        accumulated_content_bytes: usize,
385        response: &ExportResponse,
386    ) -> Result<usize, RemoteClientError> {
387        if response.memories.len() > protocol::MAX_EXPORT_PAGE_RECORDS
388            || accumulated_records
389                .checked_add(response.memories.len())
390                .is_none_or(|count| count > MemoryLimits::PRODUCTION.records)
391        {
392            return Err(RemoteClientError::InvalidResponse);
393        }
394
395        let mut previous = cursor.cloned();
396        let mut page_content_bytes = 0usize;
397        for memory in &response.memories {
398            let Some(namespace) = memory.key.namespace.as_deref() else {
399                return Err(RemoteClientError::InvalidResponse);
400            };
401            let selected = namespaces
402                .is_none_or(|selected| selected.iter().any(|candidate| candidate == namespace));
403            let ordered = previous.as_ref().is_none_or(|previous| {
404                (namespace, memory.key.id) > (previous.namespace.as_str(), previous.id)
405            });
406            if !Self::valid_record(memory) || !selected || !ordered {
407                return Err(RemoteClientError::InvalidResponse);
408            }
409
410            page_content_bytes = page_content_bytes
411                .checked_add(memory.content.len())
412                .ok_or(RemoteClientError::InvalidResponse)?;
413            if accumulated_content_bytes
414                .checked_add(page_content_bytes)
415                .is_none_or(|bytes| bytes > MemoryLimits::PRODUCTION.total_content_bytes)
416            {
417                return Err(RemoteClientError::InvalidResponse);
418            }
419            previous = Some(protocol::ExportCursor {
420                namespace: namespace.to_owned(),
421                id: memory.key.id,
422            });
423        }
424
425        if let Some(next_cursor) = &response.next_cursor {
426            let exact_last_key = response.memories.last().is_some_and(|memory| {
427                memory.key.namespace.as_deref() == Some(next_cursor.namespace.as_str())
428                    && memory.key.id == next_cursor.id
429            });
430            if !exact_last_key {
431                return Err(RemoteClientError::InvalidResponse);
432            }
433        }
434        Ok(page_content_bytes)
435    }
436
437    fn valid_key(key: &MemoryKey) -> bool {
438        key.namespace
439            .as_deref()
440            .is_none_or(protocol::is_valid_namespace)
441            && key.id > 0
442            && key.version > 0
443    }
444
445    fn valid_candidate(&self, candidate: &MemoryCandidate) -> bool {
446        Self::valid_key(&candidate.key)
447            && candidate.key.namespace.is_some()
448            && candidate.preview.len() <= 64
449            && candidate.score.is_finite()
450            && candidate.score >= 0.0
451            && !crate::secrets::contains_likely_secret(&candidate.preview)
452    }
453
454    fn valid_record(memory: &MemoryRecord) -> bool {
455        Self::valid_key(&memory.key)
456            && memory.key.namespace.is_some()
457            && !memory.content.trim().is_empty()
458            && memory.content.len() <= MemoryLimits::PRODUCTION.content_bytes
459            && memory.created_at_ms >= 0
460            && memory.updated_at_ms >= memory.created_at_ms
461            && !crate::secrets::contains_likely_secret(&memory.content)
462    }
463
464    async fn get<Response>(&self, path: &str, replay: Replay) -> Result<Response, RemoteClientError>
465    where
466        Response: DeserializeOwned,
467    {
468        self.send(path, None::<&()>, replay).await
469    }
470
471    async fn post<Request, Response>(
472        &self,
473        path: &str,
474        body: &Request,
475        replay: Replay,
476    ) -> Result<Response, RemoteClientError>
477    where
478        Request: Serialize + ?Sized,
479        Response: DeserializeOwned,
480    {
481        self.send(path, Some(body), replay).await
482    }
483
484    async fn send<Request, Response>(
485        &self,
486        path: &str,
487        body: Option<&Request>,
488        replay: Replay,
489    ) -> Result<Response, RemoteClientError>
490    where
491        Request: Serialize + ?Sized,
492        Response: DeserializeOwned,
493    {
494        let url = self
495            .inner
496            .endpoint
497            .join(path)
498            .map_err(|_| RemoteClientError::InvalidEndpoint)?;
499        for backoff in RETRY_BACKOFFS {
500            let request = match body {
501                Some(body) => self.inner.client.post(url.clone()).json(body),
502                None => self.inner.client.get(url.clone()),
503            }
504            // Reqwest owns a transient, non-zeroizing header copy for the request lifetime.
505            .bearer_auth(self.inner.token.expose())
506            .header(protocol::NAMESPACE_HEADER, &self.inner.namespace);
507
508            match request.send().await {
509                Ok(response) if response.status().is_success() => {
510                    return decode_response(response).await;
511                }
512                Ok(response) if replay == Replay::Safe && retryable_status(response.status()) => {
513                    let Some(backoff) = backoff else {
514                        return Err(response_error(response).await);
515                    };
516                    sleep(response_retry_delay(response.headers(), backoff)).await;
517                }
518                Ok(response) => return Err(response_error(response).await),
519                Err(error) if error.is_connect() => {
520                    let Some(backoff) = backoff else {
521                        return Err(RemoteClientError::Transport);
522                    };
523                    sleep(backoff).await;
524                }
525                Err(_) => return Err(RemoteClientError::Transport),
526            }
527        }
528        Err(RemoteClientError::Unavailable)
529    }
530}
531
532impl MemoryStore for RemoteMemoryClient {
533    fn scan(
534        &self,
535        query: &str,
536        limit: usize,
537    ) -> impl std::future::Future<Output = Result<MemoryScan, MemoryError>> + Send {
538        async move {
539            let candidates = RemoteMemoryClient::scan(self, query, limit).await?;
540            Ok(MemoryScan {
541                abstained: candidates.is_empty(),
542                candidates,
543            })
544        }
545    }
546    fn read(
547        &self,
548        ids: &[i64],
549        keys: &[MemoryKey],
550    ) -> impl std::future::Future<Output = Result<Vec<MemoryRecord>, MemoryError>> + Send {
551        async move { Ok(RemoteMemoryClient::read(self, ids, keys).await?) }
552    }
553    fn list(
554        &self,
555    ) -> impl std::future::Future<Output = Result<Vec<MemoryRecord>, MemoryError>> + Send {
556        async move { Ok(RemoteMemoryClient::list(self).await?) }
557    }
558    fn put(
559        &self,
560        content: &str,
561        replacement: Option<MemoryKey>,
562    ) -> impl std::future::Future<Output = Result<MemoryRecord, MemoryError>> + Send {
563        async move {
564            if content.trim().is_empty() {
565                return Err(MemoryError::EmptyContent);
566            }
567            Ok(RemoteMemoryClient::put(self, content, replacement.as_ref()).await?)
568        }
569    }
570    fn delete(
571        &self,
572        key: MemoryKey,
573    ) -> impl std::future::Future<Output = Result<(), MemoryError>> + Send {
574        async move { Ok(RemoteMemoryClient::delete(self, &key).await?) }
575    }
576    fn sync(
577        &self,
578        memories: &[MemoryRecord],
579    ) -> impl std::future::Future<Output = Result<SyncReport, MemoryError>> + Send {
580        async move { Ok(RemoteMemoryClient::sync(self, memories).await?) }
581    }
582    fn export_page(
583        &self,
584        namespaces: Option<&[String]>,
585        cursor: Option<&protocol::ExportCursor>,
586        limit: usize,
587    ) -> impl std::future::Future<
588        Output = Result<(Vec<MemoryRecord>, Option<protocol::ExportCursor>), MemoryError>,
589    > + Send {
590        let namespaces = namespaces.map(<[String]>::to_vec);
591        let cursor = cursor.cloned();
592        async move {
593            let limit = limit.clamp(1, protocol::MAX_EXPORT_PAGE_RECORDS);
594            let response: ExportResponse = self
595                .post(
596                    protocol::EXPORT_PATH,
597                    &ExportRequest {
598                        namespaces: namespaces.clone(),
599                        cursor: cursor.clone(),
600                        limit,
601                    },
602                    Replay::Safe,
603                )
604                .await?;
605            if response.memories.len() > limit {
606                return Err(RemoteClientError::InvalidResponse.into());
607            }
608            Self::validate_export_page(namespaces.as_deref(), cursor.as_ref(), 0, 0, &response)?;
609            Ok((response.memories, response.next_cursor))
610        }
611    }
612}
613
614#[derive(Clone, Copy, Eq, PartialEq)]
615enum Replay {
616    Safe,
617    ConnectOnly,
618}
619
620fn retryable_status(status: StatusCode) -> bool {
621    matches!(
622        status,
623        StatusCode::TOO_MANY_REQUESTS
624            | StatusCode::BAD_GATEWAY
625            | StatusCode::SERVICE_UNAVAILABLE
626            | StatusCode::GATEWAY_TIMEOUT
627    )
628}
629
630fn response_retry_delay(headers: &reqwest::header::HeaderMap, fallback: Duration) -> Duration {
631    headers
632        .get(reqwest::header::RETRY_AFTER)
633        .and_then(|value| value.to_str().ok())
634        .and_then(|value| value.parse::<u64>().ok())
635        .map(|seconds| Duration::from_secs(seconds.min(2)))
636        .unwrap_or(fallback)
637}
638
639async fn response_error(response: Response) -> RemoteClientError {
640    let status = response.status();
641    let code = decode_response::<ErrorResponse>(response)
642        .await
643        .ok()
644        .map(|response| response.code);
645    match (status, code) {
646        (StatusCode::UNAUTHORIZED, _) | (_, Some(RemoteErrorCode::Unauthorized)) => {
647            RemoteClientError::Unauthorized
648        }
649        (StatusCode::FORBIDDEN, Some(RemoteErrorCode::NamespaceMismatch)) => {
650            RemoteClientError::NamespaceMismatch
651        }
652        (StatusCode::FORBIDDEN, _) | (_, Some(RemoteErrorCode::Forbidden)) => {
653            RemoteClientError::ReadOnly
654        }
655        (_, Some(RemoteErrorCode::UnsupportedProtocol)) => RemoteClientError::IncompatibleProtocol,
656        (_, Some(code)) => RemoteClientError::Rejected { code },
657        (StatusCode::NOT_FOUND, None) => RemoteClientError::IncompatibleProtocol,
658        (_, None) if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS => {
659            RemoteClientError::Unavailable
660        }
661        _ => RemoteClientError::InvalidResponse,
662    }
663}
664
665async fn decode_response<Decoded>(mut response: Response) -> Result<Decoded, RemoteClientError>
666where
667    Decoded: DeserializeOwned,
668{
669    let mut bytes = Vec::new();
670    while let Some(chunk) = response
671        .chunk()
672        .await
673        .map_err(|_| RemoteClientError::InvalidResponse)?
674    {
675        if bytes.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES {
676            return Err(RemoteClientError::InvalidResponse);
677        }
678        bytes.extend_from_slice(&chunk);
679    }
680    serde_json::from_slice(&bytes).map_err(|_| RemoteClientError::InvalidResponse)
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686    use protocol::ExportCursor;
687
688    fn memory(namespace: &str, id: i64, content: &str) -> MemoryRecord {
689        MemoryRecord {
690            key: MemoryKey::remote(namespace.to_owned(), id, 1),
691            content: content.to_owned(),
692            created_at_ms: 1,
693            updated_at_ms: 1,
694            last_scanned_at_ms: None,
695            scan_count: 0,
696            last_used_at_ms: None,
697            use_count: 0,
698            probation_until_ms: None,
699        }
700    }
701
702    fn response(memories: Vec<MemoryRecord>, next: Option<(&str, i64)>) -> ExportResponse {
703        ExportResponse {
704            memories,
705            next_cursor: next.map(|(namespace, id)| ExportCursor {
706                namespace: namespace.to_owned(),
707                id,
708            }),
709        }
710    }
711
712    fn invalid(result: Result<usize, RemoteClientError>) -> bool {
713        matches!(result, Err(RemoteClientError::InvalidResponse))
714    }
715
716    #[tokio::test]
717    async fn delete_rejects_a_key_without_the_authenticated_namespace() {
718        let token = RemoteToken::new("test-token".to_owned()).unwrap();
719        let client =
720            RemoteMemoryClient::new("http://127.0.0.1:1/", "alice".to_owned(), token).unwrap();
721
722        assert!(matches!(
723            client.delete(&MemoryKey::local(1, 1)).await,
724            Err(RemoteClientError::NamespaceMismatch)
725        ));
726    }
727
728    #[test]
729    fn client_failures_preserve_memory_error_semantics() {
730        assert!(MemoryError::from(RemoteClientError::Transport).is_retryable());
731        assert!(MemoryError::from(RemoteClientError::Unavailable).is_retryable());
732        assert!(
733            MemoryError::from(RemoteClientError::Rejected {
734                code: RemoteErrorCode::Unavailable,
735            })
736            .is_retryable()
737        );
738        assert!(!MemoryError::from(RemoteClientError::InvalidResponse).is_retryable());
739        assert!(matches!(
740            MemoryError::from(RemoteClientError::Rejected {
741                code: RemoteErrorCode::Conflict,
742            }),
743            MemoryError::Conflict
744        ));
745    }
746
747    #[test]
748    fn export_page_enforces_namespace_and_exact_cursor() {
749        let selected = ["alpha".to_owned()];
750
751        assert_eq!(
752            RemoteMemoryClient::validate_export_page(
753                Some(&selected),
754                None,
755                0,
756                0,
757                &response(vec![memory("alpha", 1, "one")], Some(("alpha", 1))),
758            )
759            .expect("valid page"),
760            3
761        );
762        assert!(invalid(RemoteMemoryClient::validate_export_page(
763            Some(&selected),
764            None,
765            0,
766            0,
767            &response(vec![memory("beta", 1, "one")], None),
768        )));
769        assert!(invalid(RemoteMemoryClient::validate_export_page(
770            Some(&selected),
771            None,
772            0,
773            0,
774            &response(vec![memory("alpha", 1, "one")], Some(("alpha", 2))),
775        )));
776    }
777
778    #[test]
779    fn export_page_rejects_duplicate_and_out_of_order_keys() {
780        assert!(invalid(RemoteMemoryClient::validate_export_page(
781            None,
782            None,
783            0,
784            0,
785            &response(
786                vec![memory("alpha", 1, "one"), memory("alpha", 1, "two")],
787                None,
788            ),
789        )));
790        assert!(invalid(RemoteMemoryClient::validate_export_page(
791            None,
792            Some(&ExportCursor {
793                namespace: "beta".to_owned(),
794                id: 2,
795            }),
796            2,
797            6,
798            &response(vec![memory("alpha", 1, "one")], Some(("alpha", 1))),
799        )));
800    }
801
802    #[test]
803    fn export_page_rejects_multi_cursor_cycles() {
804        let first = response(vec![memory("alpha", 1, "one")], Some(("alpha", 1)));
805        let second = response(vec![memory("beta", 1, "two")], Some(("beta", 1)));
806        let cycle = response(vec![memory("alpha", 1, "one")], Some(("alpha", 1)));
807
808        assert_eq!(
809            RemoteMemoryClient::validate_export_page(None, None, 0, 0, &first)
810                .expect("valid first page"),
811            3
812        );
813        assert_eq!(
814            RemoteMemoryClient::validate_export_page(
815                None,
816                first.next_cursor.as_ref(),
817                1,
818                3,
819                &second,
820            )
821            .expect("valid second page"),
822            3
823        );
824        assert!(invalid(RemoteMemoryClient::validate_export_page(
825            None,
826            second.next_cursor.as_ref(),
827            2,
828            6,
829            &cycle,
830        )));
831    }
832
833    #[test]
834    fn export_page_rejects_aggregate_limits_before_accumulation() {
835        let page = response(vec![memory("alpha", 1, "x")], None);
836
837        assert!(invalid(RemoteMemoryClient::validate_export_page(
838            None,
839            None,
840            MemoryLimits::PRODUCTION.records,
841            0,
842            &page,
843        )));
844        assert!(invalid(RemoteMemoryClient::validate_export_page(
845            None,
846            None,
847            0,
848            MemoryLimits::PRODUCTION.total_content_bytes,
849            &page,
850        )));
851    }
852}