Skip to main content

lance_context_api/
lib.rs

1use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::future::Future;
6
7// ---------------------------------------------------------------------------
8// Unified error
9// ---------------------------------------------------------------------------
10
11#[derive(Debug, thiserror::Error)]
12pub enum ContextError {
13    #[error("{0}")]
14    NotFound(String),
15    #[error("{0}")]
16    AlreadyExists(String),
17    #[error("{0}")]
18    InvalidRequest(String),
19    #[error("{0}")]
20    Internal(String),
21    #[error("Compaction already in progress")]
22    CompactionInProgress,
23}
24
25pub type ContextResult<T> = Result<T, ContextError>;
26
27// ---------------------------------------------------------------------------
28// Unified trait
29// ---------------------------------------------------------------------------
30
31pub trait ContextStoreApi {
32    fn add(
33        &mut self,
34        records: &[AddRecordRequest],
35    ) -> impl Future<Output = ContextResult<AddRecordsResponse>> + Send;
36
37    fn upsert(
38        &mut self,
39        request: &UpsertRecordRequest,
40    ) -> impl Future<Output = ContextResult<UpsertRecordResponse>> + Send;
41
42    fn upsert_many(
43        &mut self,
44        request: &UpsertRecordsRequest,
45    ) -> impl Future<Output = ContextResult<UpsertRecordsResponse>> + Send;
46
47    fn update(
48        &mut self,
49        request: &UpdateRecordRequest,
50    ) -> impl Future<Output = ContextResult<UpdateRecordResponse>> + Send;
51
52    fn get(&self, id: &str) -> impl Future<Output = ContextResult<Option<RecordDto>>> + Send;
53
54    fn get_by_external_id(
55        &self,
56        external_id: &str,
57    ) -> impl Future<Output = ContextResult<Option<RecordDto>>> + Send;
58
59    fn delete_by_id(
60        &mut self,
61        id: &str,
62    ) -> impl Future<Output = ContextResult<DeleteRecordResponse>> + Send;
63
64    fn delete_by_external_id(
65        &mut self,
66        external_id: &str,
67    ) -> impl Future<Output = ContextResult<DeleteRecordResponse>> + Send;
68
69    fn list(
70        &self,
71        limit: Option<usize>,
72        offset: Option<usize>,
73        filters: Option<Value>,
74        include_expired: bool,
75        include_retired: bool,
76    ) -> impl Future<Output = ContextResult<Vec<RecordDto>>> + Send;
77
78    fn related(
79        &self,
80        target_id: &str,
81        relation: Option<&str>,
82        limit: Option<usize>,
83        include_expired: bool,
84        include_retired: bool,
85    ) -> impl Future<Output = ContextResult<Vec<RecordDto>>> + Send;
86
87    fn search(
88        &self,
89        request: &SearchRequest,
90    ) -> impl Future<Output = ContextResult<Vec<SearchResultDto>>> + Send;
91
92    fn retrieve(
93        &self,
94        request: &RetrieveRequest,
95    ) -> impl Future<Output = ContextResult<Vec<RetrieveResultDto>>> + Send;
96
97    fn version(&self) -> u64;
98
99    fn checkout(&mut self, version: u64) -> impl Future<Output = ContextResult<()>> + Send;
100
101    fn compact(
102        &mut self,
103        options: Option<CompactRequest>,
104    ) -> impl Future<Output = ContextResult<CompactResponse>> + Send;
105
106    fn compaction_stats(&self) -> impl Future<Output = ContextResult<CompactStatsResponse>> + Send;
107}
108
109// ---------------------------------------------------------------------------
110// Context lifecycle
111// ---------------------------------------------------------------------------
112
113#[derive(Debug, Serialize, Deserialize)]
114pub struct CreateContextRequest {
115    pub name: String,
116    #[serde(default)]
117    pub storage_options: Option<std::collections::HashMap<String, String>>,
118    #[serde(default)]
119    pub id_index_type: Option<String>,
120    #[serde(default)]
121    pub blob_columns: Option<Vec<String>>,
122    #[serde(default)]
123    pub embedding_dim: Option<i32>,
124    #[serde(default)]
125    pub distance_metric: Option<String>,
126}
127
128#[derive(Debug, Serialize, Deserialize)]
129pub struct ContextInfo {
130    pub name: String,
131    pub uri: String,
132    pub version: u64,
133}
134
135#[derive(Debug, Serialize, Deserialize)]
136pub struct ListContextsResponse {
137    pub contexts: Vec<ContextInfo>,
138}
139
140// ---------------------------------------------------------------------------
141// Records
142// ---------------------------------------------------------------------------
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct StateMetadataDto {
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub step: Option<i32>,
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub active_plan_id: Option<String>,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub tokens_used: Option<i32>,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub custom: Option<String>,
154}
155
156#[derive(Debug, Clone, Default, Serialize, Deserialize)]
157pub struct RelationshipDto {
158    pub target_id: String,
159    pub relation: String,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub weight: Option<f32>,
162}
163
164#[derive(Debug, Clone, Default, Serialize, Deserialize)]
165pub struct AddRecordRequest {
166    #[serde(default = "default_role")]
167    pub role: String,
168    #[serde(default = "default_content_type")]
169    pub content_type: String,
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub text_payload: Option<String>,
172    #[serde(
173        default,
174        skip_serializing_if = "Option::is_none",
175        serialize_with = "serialize_base64_opt",
176        deserialize_with = "deserialize_base64_opt"
177    )]
178    pub binary_payload: Option<Vec<u8>>,
179    /// Typed reference to a payload object stored outside the dataset
180    /// (e.g. `gs://bucket/prefix/<id>`). Distinct from inline `binary_payload`.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub payload_uri: Option<String>,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub payload_size: Option<i64>,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub payload_checksum: Option<String>,
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub embedding: Option<Vec<f32>>,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub bot_id: Option<String>,
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub session_id: Option<String>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub tenant: Option<String>,
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub source: Option<String>,
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub external_id: Option<String>,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub state_metadata: Option<StateMetadataDto>,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub metadata: Option<Value>,
203    #[serde(default, skip_serializing_if = "Vec::is_empty")]
204    pub relationships: Vec<RelationshipDto>,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub expires_at: Option<DateTime<Utc>>,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub retention_policy: Option<String>,
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub supersedes_id: Option<String>,
211}
212
213#[derive(Debug, Serialize, Deserialize)]
214pub struct AddRecordsRequest {
215    pub records: Vec<AddRecordRequest>,
216}
217
218#[derive(Debug, Serialize, Deserialize)]
219pub struct AddRecordsResponse {
220    pub version: u64,
221    pub ids: Vec<String>,
222    pub count: usize,
223}
224
225#[derive(Debug, Serialize, Deserialize)]
226pub struct UpsertRecordRequest {
227    pub record: AddRecordRequest,
228    #[serde(default = "default_upsert_key")]
229    pub key: String,
230}
231
232#[derive(Debug, Serialize, Deserialize)]
233pub struct UpsertRecordResponse {
234    pub version: u64,
235    pub inserted: bool,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub replaced_id: Option<String>,
238    pub record: RecordDto,
239}
240
241#[derive(Debug, Serialize, Deserialize)]
242pub struct UpsertRecordsRequest {
243    pub records: Vec<AddRecordRequest>,
244    #[serde(default = "default_upsert_key")]
245    pub key: String,
246}
247
248/// Per-record outcome of a batch upsert, in input order.
249#[derive(Debug, Serialize, Deserialize)]
250pub struct UpsertResultDto {
251    pub inserted: bool,
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub replaced_id: Option<String>,
254    pub record: RecordDto,
255}
256
257#[derive(Debug, Serialize, Deserialize)]
258pub struct UpsertRecordsResponse {
259    pub version: u64,
260    pub results: Vec<UpsertResultDto>,
261}
262
263#[derive(Debug, Clone, Default, Serialize, Deserialize)]
264pub struct RecordPatchDto {
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub bot_id: Option<String>,
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub session_id: Option<String>,
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub tenant: Option<String>,
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub source: Option<String>,
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub state_metadata: Option<StateMetadataDto>,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub metadata: Option<Value>,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub relationships: Option<Vec<RelationshipDto>>,
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub expires_at: Option<DateTime<Utc>>,
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub retention_policy: Option<String>,
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub lifecycle_status: Option<String>,
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub retired_at: Option<DateTime<Utc>>,
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub retired_reason: Option<String>,
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub embedding: Option<Vec<f32>>,
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub payload_uri: Option<String>,
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub payload_size: Option<i64>,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub payload_checksum: Option<String>,
297}
298
299impl RecordPatchDto {
300    #[must_use]
301    pub fn is_empty(&self) -> bool {
302        self.bot_id.is_none()
303            && self.session_id.is_none()
304            && self.tenant.is_none()
305            && self.source.is_none()
306            && self.state_metadata.is_none()
307            && self.metadata.is_none()
308            && self.relationships.is_none()
309            && self.expires_at.is_none()
310            && self.retention_policy.is_none()
311            && self.lifecycle_status.is_none()
312            && self.retired_at.is_none()
313            && self.retired_reason.is_none()
314            && self.embedding.is_none()
315            && self.payload_uri.is_none()
316            && self.payload_size.is_none()
317            && self.payload_checksum.is_none()
318    }
319}
320
321#[derive(Debug, Serialize, Deserialize)]
322pub struct UpdateRecordRequest {
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub id: Option<String>,
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub external_id: Option<String>,
327    #[serde(default)]
328    pub patch: RecordPatchDto,
329}
330
331#[derive(Debug, Serialize, Deserialize)]
332pub struct UpdateRecordResponse {
333    pub version: u64,
334    pub updated: bool,
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub replaced_id: Option<String>,
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub record: Option<RecordDto>,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct RecordDto {
343    pub id: String,
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub external_id: Option<String>,
346    pub run_id: String,
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub bot_id: Option<String>,
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub session_id: Option<String>,
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub tenant: Option<String>,
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub source: Option<String>,
355    pub created_at: DateTime<Utc>,
356    pub role: String,
357    pub content_type: String,
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub text_payload: Option<String>,
360    #[serde(
361        default,
362        skip_serializing_if = "Option::is_none",
363        serialize_with = "serialize_base64_opt",
364        deserialize_with = "deserialize_base64_opt"
365    )]
366    pub binary_payload: Option<Vec<u8>>,
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub payload_uri: Option<String>,
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub payload_size: Option<i64>,
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub payload_checksum: Option<String>,
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub embedding: Option<Vec<f32>>,
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub state_metadata: Option<StateMetadataDto>,
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub metadata: Option<Value>,
379    #[serde(default, skip_serializing_if = "Vec::is_empty")]
380    pub relationships: Vec<RelationshipDto>,
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub expires_at: Option<DateTime<Utc>>,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub retention_policy: Option<String>,
385    pub lifecycle_status: String,
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub retired_at: Option<DateTime<Utc>>,
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub retired_reason: Option<String>,
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub supersedes_id: Option<String>,
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub superseded_by_id: Option<String>,
394}
395
396#[derive(Debug, Serialize, Deserialize)]
397pub struct ListRecordsResponse {
398    pub records: Vec<RecordDto>,
399}
400
401// ---------------------------------------------------------------------------
402// Single record lookup
403// ---------------------------------------------------------------------------
404
405#[derive(Debug, Serialize, Deserialize)]
406pub struct GetRecordResponse {
407    pub record: Option<RecordDto>,
408}
409
410#[derive(Debug, Serialize, Deserialize)]
411pub struct DeleteRecordResponse {
412    pub deleted: bool,
413    pub version: u64,
414}
415
416// ---------------------------------------------------------------------------
417// Search
418// ---------------------------------------------------------------------------
419
420#[derive(Debug, Serialize, Deserialize)]
421pub struct SearchRequest {
422    pub query: Vec<f32>,
423    #[serde(default = "default_search_limit")]
424    pub limit: usize,
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub filters: Option<Value>,
427    #[serde(default)]
428    pub include_expired: bool,
429    #[serde(default)]
430    pub include_retired: bool,
431    #[serde(default)]
432    pub include_relationships: bool,
433}
434
435#[derive(Debug, Serialize, Deserialize)]
436pub struct SearchResultDto {
437    pub record: RecordDto,
438    pub distance: f32,
439}
440
441#[derive(Debug, Serialize, Deserialize)]
442pub struct SearchResponse {
443    pub results: Vec<SearchResultDto>,
444}
445
446// ---------------------------------------------------------------------------
447// Hybrid retrieval
448// ---------------------------------------------------------------------------
449
450#[derive(Debug, Serialize, Deserialize)]
451pub struct RetrieveRequest {
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub text: Option<String>,
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub vector: Option<Vec<f32>>,
456    #[serde(default = "default_search_limit")]
457    pub limit: usize,
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub filters: Option<Value>,
460    #[serde(default)]
461    pub include_expired: bool,
462    #[serde(default)]
463    pub include_retired: bool,
464    #[serde(default)]
465    pub include_relationships: bool,
466    #[serde(default = "default_retrieve_fusion")]
467    pub fusion: String,
468}
469
470#[derive(Debug, Serialize, Deserialize)]
471pub struct RetrieveResultDto {
472    pub record: RecordDto,
473    pub score: f32,
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub vector_distance: Option<f32>,
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub text_score: Option<f32>,
478    #[serde(default, skip_serializing_if = "Vec::is_empty")]
479    pub matched_channels: Vec<String>,
480}
481
482#[derive(Debug, Serialize, Deserialize)]
483pub struct RetrieveResponse {
484    pub results: Vec<RetrieveResultDto>,
485}
486
487// ---------------------------------------------------------------------------
488// Versioning
489// ---------------------------------------------------------------------------
490
491#[derive(Debug, Serialize, Deserialize)]
492pub struct VersionResponse {
493    pub version: u64,
494}
495
496#[derive(Debug, Serialize, Deserialize)]
497pub struct CheckoutRequest {
498    pub version: u64,
499}
500
501// ---------------------------------------------------------------------------
502// Compaction
503// ---------------------------------------------------------------------------
504
505#[derive(Debug, Default, Serialize, Deserialize)]
506pub struct CompactRequest {
507    #[serde(default, skip_serializing_if = "Option::is_none")]
508    pub target_rows_per_fragment: Option<usize>,
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub materialize_deletions: Option<bool>,
511}
512
513#[derive(Debug, Serialize, Deserialize)]
514pub struct CompactResponse {
515    pub fragments_removed: usize,
516    pub fragments_added: usize,
517    pub files_removed: usize,
518    pub files_added: usize,
519}
520
521#[derive(Debug, Serialize, Deserialize)]
522pub struct CompactStatsResponse {
523    pub total_fragments: usize,
524    pub is_compacting: bool,
525    #[serde(default, skip_serializing_if = "Option::is_none")]
526    pub last_compaction: Option<DateTime<Utc>>,
527    #[serde(default, skip_serializing_if = "Option::is_none")]
528    pub last_error: Option<String>,
529    pub total_compactions: u64,
530}
531
532// ---------------------------------------------------------------------------
533// Error
534// ---------------------------------------------------------------------------
535
536#[derive(Debug, Serialize, Deserialize)]
537pub struct ErrorBody {
538    pub code: String,
539    pub message: String,
540}
541
542#[derive(Debug, Serialize, Deserialize)]
543pub struct ErrorResponse {
544    pub error: ErrorBody,
545}
546
547// ---------------------------------------------------------------------------
548// Helpers
549// ---------------------------------------------------------------------------
550
551fn default_content_type() -> String {
552    "text/plain".to_string()
553}
554
555fn default_role() -> String {
556    "user".to_string()
557}
558
559fn default_upsert_key() -> String {
560    "external_id".to_string()
561}
562
563fn default_search_limit() -> usize {
564    10
565}
566
567fn default_retrieve_fusion() -> String {
568    "rrf".to_string()
569}
570
571fn serialize_base64_opt<S>(data: &Option<Vec<u8>>, serializer: S) -> Result<S::Ok, S::Error>
572where
573    S: serde::Serializer,
574{
575    match data {
576        Some(bytes) => serializer.serialize_some(&BASE64.encode(bytes)),
577        None => serializer.serialize_none(),
578    }
579}
580
581fn deserialize_base64_opt<'de, D>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error>
582where
583    D: serde::Deserializer<'de>,
584{
585    let opt: Option<String> = Option::deserialize(deserializer)?;
586    match opt {
587        Some(s) => BASE64
588            .decode(&s)
589            .map(Some)
590            .map_err(serde::de::Error::custom),
591        None => Ok(None),
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn search_request_legacy_payload_defaults_filters_and_lifecycle() {
601        // Clients written against the pre-#89 shape send only query/limit.
602        let req: SearchRequest =
603            serde_json::from_str(r#"{"query": [0.1, 0.2], "limit": 5}"#).unwrap();
604        assert_eq!(req.query, vec![0.1, 0.2]);
605        assert_eq!(req.limit, 5);
606        assert!(req.filters.is_none());
607        assert!(!req.include_expired);
608        assert!(!req.include_retired);
609        assert!(!req.include_relationships);
610    }
611
612    #[test]
613    fn search_request_defaults_limit_when_omitted() {
614        let req: SearchRequest = serde_json::from_str(r#"{"query": [1.0]}"#).unwrap();
615        assert_eq!(req.limit, default_search_limit());
616    }
617
618    #[test]
619    fn search_request_parses_filters_and_lifecycle() {
620        let req: SearchRequest = serde_json::from_str(
621            r#"{"query": [1.0], "filters": {"tenant": "acme"}, "include_expired": true, "include_retired": true}"#,
622        )
623        .unwrap();
624        assert_eq!(req.filters, Some(serde_json::json!({"tenant": "acme"})));
625        assert!(req.include_expired);
626        assert!(req.include_retired);
627    }
628
629    #[test]
630    fn add_request_omits_payload_reference_when_absent() {
631        // Records without an external reference must not emit the new keys, so
632        // older servers/clients keep round-tripping unchanged.
633        let req = AddRecordRequest {
634            role: "user".to_string(),
635            content_type: "text/plain".to_string(),
636            text_payload: Some("hi".to_string()),
637            ..Default::default()
638        };
639        let json = serde_json::to_string(&req).unwrap();
640        assert!(!json.contains("payload_uri"));
641        assert!(!json.contains("payload_size"));
642        assert!(!json.contains("payload_checksum"));
643    }
644
645    #[test]
646    fn add_request_roundtrips_payload_reference() {
647        let req = AddRecordRequest {
648            role: "user".to_string(),
649            content_type: "image/png".to_string(),
650            payload_uri: Some("gs://bucket/prefix/obj.png".to_string()),
651            payload_size: Some(2048),
652            payload_checksum: Some("sha256:abc".to_string()),
653            ..Default::default()
654        };
655        let json = serde_json::to_string(&req).unwrap();
656        let back: AddRecordRequest = serde_json::from_str(&json).unwrap();
657        assert_eq!(
658            back.payload_uri.as_deref(),
659            Some("gs://bucket/prefix/obj.png")
660        );
661        assert_eq!(back.payload_size, Some(2048));
662        assert_eq!(back.payload_checksum.as_deref(), Some("sha256:abc"));
663    }
664
665    #[test]
666    fn record_dto_decodes_payload_reference_and_legacy_shape() {
667        // New shape with a reference.
668        let dto: RecordDto = serde_json::from_str(
669            r#"{"id":"r1","run_id":"run","created_at":"2026-06-27T00:00:00Z","role":"user","content_type":"image/png","lifecycle_status":"active","payload_uri":"s3://b/obj","payload_size":10}"#,
670        )
671        .unwrap();
672        assert_eq!(dto.payload_uri.as_deref(), Some("s3://b/obj"));
673        assert_eq!(dto.payload_size, Some(10));
674        assert_eq!(dto.payload_checksum, None);
675
676        // Legacy shape lacking the reference fields still decodes.
677        let legacy: RecordDto = serde_json::from_str(
678            r#"{"id":"r1","run_id":"run","created_at":"2026-06-27T00:00:00Z","role":"user","content_type":"text/plain","lifecycle_status":"active"}"#,
679        )
680        .unwrap();
681        assert_eq!(legacy.payload_uri, None);
682    }
683}