Skip to main content

agent_context_contract/
lib.rs

1//! HTTP API contract for the Context service.
2//!
3//! # Why this service exists
4//!
5//! Conversation history is durable, cross-turn state. A Runtime must be able to
6//! read and append it without owning a database and without holding a platform
7//! service secret. Giving history its own service does for history what
8//! `agent-workspace-infra` does for sandboxes: one owner, one HTTP contract, one
9//! deployable.
10//!
11//! # Layering
12//!
13//! ```text
14//! agent-context-contract   paths + DTOs        (this crate)
15//! agent-context-runtime    store implementation
16//! agent-context-server     router + auth + bin
17//! ```
18//!
19//! See [`api.md`](../api.md) for the full HTTP reference.
20
21use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24
25mod records;
26pub use records::*;
27
28pub const SERVICE_NAME: &str = "agent-context-infra";
29pub const DEFAULT_PORT: u16 = 5120;
30pub const AUDIENCE: &str = "agent-context-infra";
31pub const MAX_RESOURCE_ID_CHARS: usize = 191;
32
33/// Optional static service token. When unset, the server runs in open mode.
34pub const SERVICE_TOKEN_ENV: &str = "AGENT_CONTEXT_SERVICE_TOKEN";
35
36pub const HEALTH_PATH: &str = "/health";
37pub const MESSAGES_APPEND_PATH: &str = "/internal/v1/context/messages/batch";
38pub const MESSAGES_RECENT_PATH: &str = "/internal/v1/context/messages/recent";
39pub const MESSAGES_SEARCH_PATH: &str = "/internal/v1/context/messages/search";
40pub const MESSAGES_PAGE_PATH: &str = "/internal/v1/context/messages";
41pub const CONVERSATION_DELETE_PATH: &str = "/internal/v1/context/conversations/delete";
42pub const RETENTION_PATH: &str = "/internal/v1/context/conversations/retention";
43pub const CONTEXT_OPERATIONS_PATH: &str = "/internal/v1/context/operations";
44pub const MAINTENANCE_STATUS_PATH: &str = "/internal/v1/context/maintenance/status";
45pub const CONTEXT_INSIGHTS_PATH: &str = "/internal/v1/context/entries";
46pub const CONTEXT_INSIGHT_GET_PATH: &str = "/internal/v1/context/entries/get";
47pub const CONTEXT_RECALL_PATH: &str = "/internal/v1/context/recall";
48pub const CONTEXT_MERGE_PATH: &str = "/internal/v1/context/entries/merge";
49pub const CONTEXT_CORRECT_PATH: &str = "/internal/v1/context/entries/correct";
50pub const CONTEXT_EXPIRE_PATH: &str = "/internal/v1/context/entries/expire";
51pub const CONTEXT_EXPORT_PATH: &str = "/internal/v1/context/exports";
52pub const CONTEXT_IMPORT_PATH: &str = "/internal/v1/context/imports";
53pub const VECTOR_REBUILD_PATH: &str = "/internal/v1/context/projections/vector/rebuild";
54pub const CONTEXT_ADVANCED_OPERATIONS_PATH: &str = "/internal/v1/context/advanced-operations";
55pub const CONTEXT_EXPORT_ARTIFACT_PATH: &str = "/internal/v1/context/export-artifacts/get";
56pub const CONTEXT_EXPORT_ARTIFACT_CHUNK_PATH: &str = "/internal/v1/context/export-artifacts/chunk";
57
58// Trace events are NOT served here. They belong to `agent-trace` (Trace Infra service)
59// (`/internal/v1/trace-infra/events/batch`).
60//
61// Two services accepting the same records would mean two write paths for one
62// dataset, and a run's trace would split depending on which endpoint a client
63// happened to call. Trace also needs different guarantees: bounded retention,
64// server-side credential redaction, and a hard rule that it is never replayed
65// into a model prompt.
66
67/// Identifies the conversation a record belongs to.
68///
69/// `conversation_id` is the only required field: it is the partition key. The
70/// rest are optional provenance used for filtering and auditing.
71#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct ConversationScope {
74    pub conversation_id: String,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub user_id: Option<String>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub agent_id: Option<String>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub agent_space_id: Option<String>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub runtime_type: Option<String>,
83    /// Many-to-many domain associations. Legacy singular provenance fields
84    /// above remain readable but are not the canonical relation model.
85    #[serde(default)]
86    pub relations: agent_registry_contract::ResourceRelations,
87}
88
89/// Scope derived from a verified gateway-issued internal credential.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase", deny_unknown_fields)]
92pub struct AuthorizedScope {
93    pub tenant_id: String,
94    pub project_id: String,
95    pub subject: String,
96}
97
98impl ConversationScope {
99    pub fn conversation(conversation_id: impl Into<String>) -> Self {
100        Self {
101            conversation_id: conversation_id.into(),
102            relations: Default::default(),
103            ..Default::default()
104        }
105    }
106}
107
108/// One durable conversation message.
109///
110/// `content` is a `Value` rather than a `String` because assistant turns carry
111/// structured tool calls. Storing the real shape avoids a lossy round-trip
112/// through a stringified envelope.
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114#[serde(rename_all = "camelCase")]
115pub struct ConversationRecord {
116    /// Caller supplied id. Used for idempotent append: re-sending the same id is
117    /// a no-op, so a retry after a timeout cannot duplicate a turn.
118    pub id: String,
119    pub scope: ConversationScope,
120    /// `user`, `assistant`, `tool`, `system`, or `event`.
121    pub role: String,
122    pub content: Value,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub name: Option<String>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub tool_call_id: Option<String>,
127    /// Monotonic order within a conversation. This value is assigned by the
128    /// service; caller-provided values are ignored on the normal append path.
129    #[serde(default)]
130    pub sequence: i64,
131    #[serde(default)]
132    pub created_at_ms: i64,
133    #[serde(default, skip_serializing_if = "Value::is_null")]
134    pub metadata: Value,
135}
136
137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase")]
139pub struct AppendMessagesRequest {
140    pub messages: Vec<ConversationRecord>,
141}
142
143/// Result of an append. `appended` excludes ids that already existed, so a
144/// caller can tell a fresh write from a deduplicated retry.
145#[derive(Debug, Clone, Default, Serialize, Deserialize)]
146#[serde(rename_all = "camelCase")]
147pub struct AppendResponse {
148    pub appended: usize,
149    pub skipped: usize,
150}
151
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct RecentMessagesRequest {
155    pub scope: ConversationScope,
156    /// Maximum records to return. The server clamps this to [`MAX_PAGE_SIZE`].
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub limit: Option<usize>,
159    /// When set, only these roles are returned. Empty means all roles.
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub roles: Vec<String>,
162}
163
164/// Records in chronological order, oldest first.
165///
166/// The order is part of the contract. A client that has to re-sort is a client
167/// that will eventually get it wrong.
168#[derive(Debug, Clone, Default, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct MessagesResponse {
171    pub items: Vec<ConversationRecord>,
172}
173
174/// Stable opaque cursor position for `(sequence ASC, id ASC)` ordering.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "camelCase", deny_unknown_fields)]
177pub struct MessageCursor {
178    pub sequence: i64,
179    pub id: String,
180}
181
182impl MessageCursor {
183    pub fn encode(&self) -> Result<String, serde_json::Error> {
184        serde_json::to_vec(self).map(|bytes| URL_SAFE_NO_PAD.encode(bytes))
185    }
186
187    pub fn decode(value: &str) -> Result<Self, String> {
188        let bytes = URL_SAFE_NO_PAD
189            .decode(value)
190            .map_err(|_| "invalid message cursor encoding".to_string())?;
191        serde_json::from_slice(&bytes).map_err(|_| "invalid message cursor payload".to_string())
192    }
193}
194
195#[derive(Debug, Clone, Default, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct PageMessagesRequest {
198    pub scope: ConversationScope,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub cursor: Option<String>,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub limit: Option<usize>,
203    #[serde(default, skip_serializing_if = "Vec::is_empty")]
204    pub roles: Vec<String>,
205}
206
207#[derive(Debug, Clone, Default, Serialize, Deserialize)]
208#[serde(rename_all = "camelCase")]
209pub struct MessagePage {
210    pub items: Vec<ConversationRecord>,
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub next_cursor: Option<String>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216#[serde(rename_all = "camelCase", deny_unknown_fields)]
217pub struct RetentionPolicy {
218    pub max_age_days: Option<u32>,
219    pub max_messages: Option<u32>,
220}
221
222impl Default for RetentionPolicy {
223    fn default() -> Self {
224        Self {
225            max_age_days: Some(90),
226            max_messages: Some(100_000),
227        }
228    }
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase", deny_unknown_fields)]
233pub struct RetentionRequest {
234    pub scope: ConversationScope,
235    #[serde(default)]
236    pub policy: RetentionPolicy,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(rename_all = "camelCase", deny_unknown_fields)]
241pub struct DeleteConversationRequest {
242    pub scope: ConversationScope,
243    #[serde(default)]
244    pub reason: String,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248#[serde(rename_all = "camelCase")]
249pub struct DeletionOperation {
250    pub id: String,
251    pub state: String,
252    pub deleted_messages: u64,
253    pub created_at_ms: i64,
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub next_poll_after_ms: Option<u64>,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub error_code: Option<String>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(rename_all = "camelCase", deny_unknown_fields)]
262pub struct GetDeletionOperationRequest {
263    pub operation_id: String,
264}
265
266#[derive(Debug, Clone, Default, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct ContextMaintenanceStatus {
269    pub projector_last_run_ms: Option<i64>,
270    pub projector_processed: u64,
271    pub retention_last_run_ms: Option<i64>,
272    pub retention_processed: u64,
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub index_watermark: Option<IndexWatermark>,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279pub enum ContextInsightStatus {
280    Active,
281    Corrected,
282    Merged,
283    Expired,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287#[serde(rename_all = "snake_case")]
288pub enum ContextInsightKind {
289    Fact,
290    Preference,
291    Decision,
292    Task,
293    Summary,
294    Custom,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
298#[serde(rename_all = "camelCase", deny_unknown_fields)]
299pub struct ContextProvenance {
300    pub conversation_id: String,
301    #[serde(default)]
302    pub message_ids: Vec<String>,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub extractor_ref: Option<String>,
305    #[serde(default)]
306    pub observed_at_ms: i64,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
310#[serde(rename_all = "camelCase")]
311pub struct ContextInsight {
312    pub id: String,
313    pub kind: ContextInsightKind,
314    pub content: Value,
315    #[serde(default)]
316    pub attributes: Value,
317    pub version: u64,
318    pub status: ContextInsightStatus,
319    pub provenance: ContextProvenance,
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub superseded_by: Option<String>,
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub expires_at_ms: Option<i64>,
324    pub created_at_ms: i64,
325    pub updated_at_ms: i64,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(rename_all = "camelCase", deny_unknown_fields)]
330pub struct PutContextInsightRequest {
331    pub entry: ContextInsight,
332    #[serde(default)]
333    pub expected_version: u64,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(rename_all = "camelCase", deny_unknown_fields)]
338pub struct GetContextInsightRequest {
339    pub entry_id: String,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
343#[serde(rename_all = "camelCase", deny_unknown_fields)]
344pub struct CorrectContextInsightRequest {
345    pub entry_id: String,
346    pub expected_version: u64,
347    pub content: Value,
348    #[serde(default)]
349    pub reason: String,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
353#[serde(rename_all = "camelCase", deny_unknown_fields)]
354pub struct ExpireContextInsightRequest {
355    pub entry_id: String,
356    pub expected_version: u64,
357    #[serde(default)]
358    pub reason: String,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
362#[serde(rename_all = "camelCase", deny_unknown_fields)]
363pub struct MergeContextInsightsRequest {
364    pub source_entry_ids: Vec<String>,
365    pub merged_entry: ContextInsight,
366    #[serde(default)]
367    pub expected_versions: std::collections::BTreeMap<String, u64>,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
371#[serde(rename_all = "camelCase", deny_unknown_fields)]
372pub struct RecallContextRequest {
373    pub query: String,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub conversation_id: Option<String>,
376    #[serde(default = "default_recall_limit")]
377    pub limit: usize,
378    #[serde(default = "default_lexical_weight")]
379    pub lexical_weight: f64,
380    #[serde(default = "default_vector_weight")]
381    pub vector_weight: f64,
382}
383
384fn default_recall_limit() -> usize {
385    20
386}
387fn default_lexical_weight() -> f64 {
388    0.35
389}
390fn default_vector_weight() -> f64 {
391    0.65
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
395#[serde(rename_all = "camelCase")]
396pub struct RecallScoreBreakdown {
397    pub lexical: f64,
398    pub vector: f64,
399    pub recency: f64,
400    pub final_score: f64,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
404#[serde(rename_all = "camelCase")]
405pub struct RecalledContext {
406    pub entry: ContextInsight,
407    pub score: RecallScoreBreakdown,
408}
409
410#[derive(Debug, Clone, Default, Serialize, Deserialize)]
411#[serde(rename_all = "camelCase")]
412pub struct IndexWatermark {
413    pub projected_version: u64,
414    pub source_version: u64,
415    pub lag: u64,
416    pub updated_at_ms: i64,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
420#[serde(rename_all = "camelCase")]
421pub struct RecallContextResponse {
422    pub items: Vec<RecalledContext>,
423    pub index_watermark: IndexWatermark,
424}
425
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427#[serde(rename_all = "snake_case")]
428pub enum ContextOperationKind {
429    Delete,
430    Export,
431    Import,
432    RebuildVector,
433}
434
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436#[serde(rename_all = "snake_case")]
437pub enum ContextOperationState {
438    Queued,
439    Running,
440    Succeeded,
441    Failed,
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize)]
445#[serde(rename_all = "camelCase")]
446pub struct ContextOperation {
447    pub id: String,
448    pub kind: ContextOperationKind,
449    pub state: ContextOperationState,
450    pub phase: String,
451    pub processed: u64,
452    pub total: u64,
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub artifact_ref: Option<String>,
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub result: Option<Value>,
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub error: Option<String>,
459    /// Stable machine-readable terminal/retry classification. Provider and DB
460    /// messages remain in `error` and are never reused as protocol codes.
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub error_code: Option<String>,
463    pub created_at_ms: i64,
464    pub updated_at_ms: i64,
465    pub version: u64,
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub next_poll_after_ms: Option<u64>,
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize)]
471#[serde(rename_all = "camelCase", deny_unknown_fields)]
472pub struct GetContextOperationRequest {
473    pub operation_id: String,
474}
475
476#[derive(Debug, Clone, Default, Serialize, Deserialize)]
477#[serde(rename_all = "camelCase", deny_unknown_fields)]
478pub struct ExportContextRequest {
479    #[serde(default)]
480    pub include_expired: bool,
481}
482
483#[derive(Debug, Clone, Serialize, Deserialize)]
484#[serde(rename_all = "camelCase", deny_unknown_fields)]
485pub struct ImportContextRequest {
486    /// Preferred durable import source. When set, inline vectors must be
487    /// empty and the worker parses the tenant-scoped artifact incrementally.
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub artifact_ref: Option<String>,
490    #[serde(default)]
491    pub messages: Vec<ConversationRecord>,
492    #[serde(default)]
493    pub entries: Vec<ContextInsight>,
494    pub idempotency_key: String,
495}
496
497#[derive(Debug, Clone, Serialize, Deserialize)]
498#[serde(rename_all = "camelCase")]
499pub struct ContextExportArtifact {
500    pub id: String,
501    pub schema_version: u32,
502    pub messages: Vec<ConversationRecord>,
503    pub entries: Vec<ContextInsight>,
504    pub created_at_ms: i64,
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize)]
508#[serde(rename_all = "camelCase", deny_unknown_fields)]
509pub struct GetContextExportArtifactRequest {
510    pub artifact_id: String,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize)]
514#[serde(rename_all = "camelCase", deny_unknown_fields)]
515pub struct GetContextExportArtifactChunkRequest {
516    pub artifact_id: String,
517    #[serde(default)]
518    pub offset: u64,
519    #[serde(default = "default_artifact_chunk_bytes")]
520    pub limit: usize,
521}
522
523fn default_artifact_chunk_bytes() -> usize {
524    1024 * 1024
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize)]
528#[serde(rename_all = "camelCase")]
529pub struct ContextExportArtifactChunk {
530    pub artifact_id: String,
531    pub offset: u64,
532    pub data_base64: String,
533    pub next_offset: u64,
534    pub eof: bool,
535    pub digest: String,
536}
537
538#[derive(Debug, Clone, Default, Serialize, Deserialize)]
539#[serde(rename_all = "camelCase", deny_unknown_fields)]
540pub struct RebuildVectorProjectionRequest {
541    #[serde(default)]
542    pub force: bool,
543}
544
545#[derive(Debug, Clone, Default, Serialize, Deserialize)]
546#[serde(rename_all = "camelCase")]
547pub struct SearchMessagesRequest {
548    pub scope: ConversationScope,
549    /// Case-insensitive substring match against textual content.
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub query: Option<String>,
552    #[serde(default, skip_serializing_if = "Option::is_none")]
553    pub role: Option<String>,
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub limit: Option<usize>,
556}
557
558/// Upper bound the server enforces on any page size.
559///
560/// An unbounded read is a denial-of-service waiting to happen once a
561/// conversation grows, so the limit lives in the contract where both sides see
562/// it.
563pub const MAX_PAGE_SIZE: usize = 500;
564
565/// Default page size when a request omits `limit`.
566pub const DEFAULT_PAGE_SIZE: usize = 50;
567
568/// Maximum number of records accepted by one atomic append.
569pub const MAX_APPEND_MESSAGES: usize = 500;
570
571/// Maximum serialized payload bytes accepted by one atomic append.
572pub const MAX_APPEND_BYTES: usize = 8 * 1024 * 1024;
573pub const MAX_SEARCH_QUERY_BYTES: usize = 4 * 1024;
574
575/// Error body shared by every non-2xx response.
576#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct ApiError {
578    pub status: u16,
579    pub message: String,
580}
581
582/// Extract plain text from a message `content` value.
583///
584/// Content is either a string or an OpenAI-style array of parts. Callers that
585/// need text (prompt assembly, substring search) must agree on one reading, so
586/// it lives in the contract instead of being reimplemented per crate.
587pub fn content_text(content: &Value) -> String {
588    match content {
589        Value::String(value) => value.clone(),
590        Value::Array(parts) => parts
591            .iter()
592            .filter_map(|part| {
593                part.get("text")
594                    .and_then(Value::as_str)
595                    .or_else(|| part.as_str())
596            })
597            .collect::<Vec<_>>()
598            .join("\n"),
599        Value::Null => String::new(),
600        other => other.to_string(),
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607    use serde_json::json;
608
609    #[test]
610    fn scope_serialises_camel_case_and_omits_absent_provenance() {
611        let value =
612            serde_json::to_value(ConversationScope::conversation("conv-1")).expect("serialize");
613        assert_eq!(value, json!({"conversationId": "conv-1"}));
614    }
615
616    #[test]
617    fn content_text_reads_strings_and_part_arrays() {
618        assert_eq!(content_text(&json!("hello")), "hello");
619        assert_eq!(
620            content_text(&json!([{"type": "text", "text": "a"}, {"type": "text", "text": "b"}])),
621            "a\nb"
622        );
623        assert_eq!(content_text(&Value::Null), "");
624    }
625
626    #[test]
627    fn a_message_round_trips_through_json() {
628        let record = ConversationRecord {
629            id: "m-1".into(),
630            scope: ConversationScope::conversation("conv-1"),
631            role: "assistant".into(),
632            content: json!("done"),
633            name: None,
634            tool_call_id: None,
635            sequence: 7,
636            created_at_ms: 1_700_000_000_000,
637            metadata: Value::Null,
638        };
639        let encoded = serde_json::to_value(&record).expect("serialize");
640        assert_eq!(encoded["createdAtMs"], 1_700_000_000_000_i64);
641        let decoded: ConversationRecord = serde_json::from_value(encoded).expect("deserialize");
642        assert_eq!(decoded, record);
643    }
644}