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