diff --git a/README.md b/README.md
index ce587165..1918324d 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,11 @@ one).
Full documentation: ****
+For deduplicated conversation turns, see [Trace records](docs/trace-records.md):
+`TraceRecord` supplies `session_id`, ordered `turn_id`, role and content in one
+dataset, with typed local/remote access and ID-only batch lookup through the
+existing GenericStore API.
+
## Install
```bash
diff --git a/crates/lance-context-api/src/lib.rs b/crates/lance-context-api/src/lib.rs
index 8ca1a8ca..3393e2c2 100644
--- a/crates/lance-context-api/src/lib.rs
+++ b/crates/lance-context-api/src/lib.rs
@@ -1,5 +1,7 @@
pub mod schema_spec;
pub use schema_spec::{ColumnSpec, ColumnType, SchemaSpec, ID_COLUMN};
+mod trace;
+pub use trace::{trace_schema, TraceRecord, TraceStore};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use chrono::{DateTime, Utc};
diff --git a/crates/lance-context-api/src/trace.rs b/crates/lance-context-api/src/trace.rs
new file mode 100644
index 00000000..c521010d
--- /dev/null
+++ b/crates/lance-context-api/src/trace.rs
@@ -0,0 +1,251 @@
+//! A typed conversation-turn schema over the shared generic storage API.
+
+use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value};
+
+use crate::{
+ AddRowsResponse, ColumnSpec, ColumnType, ContextError, ContextResult, GenericStoreApi,
+ SchemaSpec,
+};
+
+/// One observed turn, not a complete model call or a copy of its input history.
+///
+/// `turn_id` is a zero-based position in the observed session, independent of
+/// arrival order. It is not the storage key: different content at the same
+/// `(session_id, turn_id)` must use different `id`s. Replays must reuse their ID.
+/// A caller can derive `id` from session, turn, role, content type and exact
+/// content (including null versus empty); the store does not generate it.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct TraceRecord {
+ pub id: String,
+ pub session_id: String,
+ pub turn_id: i64,
+ /// Open vocabulary: user, assistant, system, tool, or a source-specific role.
+ pub role: String,
+ /// Exact turn content. Null and an empty string remain distinct.
+ pub content: Option,
+ #[serde(default = "text_plain")]
+ pub content_type: String,
+ /// Source dataset/system, not an individual occurrence of this shared turn.
+ #[serde(default)]
+ pub source: Option,
+ /// Optional source-specific annotations, physically encoded as JSON text.
+ /// This does not implicitly accumulate provenance when an ID is replayed.
+ #[serde(default)]
+ pub metadata: Option,
+}
+
+fn text_plain() -> String {
+ "text/plain".into()
+}
+
+impl TraceRecord {
+ pub fn validate(&self) -> ContextResult<()> {
+ for (name, value) in [
+ ("id", &self.id),
+ ("session_id", &self.session_id),
+ ("role", &self.role),
+ ("content_type", &self.content_type),
+ ] {
+ if value.trim().is_empty() {
+ return Err(ContextError::InvalidRequest(format!(
+ "trace {name} must not be empty"
+ )));
+ }
+ }
+ if self.turn_id < 0 {
+ return Err(ContextError::InvalidRequest(
+ "trace turn_id must be nonnegative".into(),
+ ));
+ }
+ Ok(())
+ }
+
+ /// Encode for the existing generic batch/HTTP API, validating before I/O.
+ pub fn to_row(&self) -> ContextResult