use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::agentlog::hash;
pub const CURRENT_VERSION: &str = "0.1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Record {
pub version: String,
pub id: String,
pub kind: Kind,
pub ts: String,
pub parent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub meta: Option<Map<String, Value>>,
pub payload: Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
Metadata,
ChatRequest,
ChatResponse,
ToolCall,
ToolResult,
Error,
ReplaySummary,
Chunk,
HarnessEvent,
BlobRef,
}
impl Record {
pub fn new(kind: Kind, payload: Value, ts: impl Into<String>, parent: Option<String>) -> Self {
let id = hash::content_id(&payload);
Self {
version: CURRENT_VERSION.to_string(),
id,
kind,
ts: ts.into(),
parent,
meta: None,
payload,
}
}
pub fn with_meta(mut self, meta: Map<String, Value>) -> Self {
self.meta = Some(meta);
self
}
pub fn verify_id(&self) -> bool {
self.id == hash::content_id(&self.payload)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn sample_payload() -> Value {
json!({ "model": "claude-opus-4-7", "messages": [] })
}
#[test]
fn new_computes_id_from_payload() {
let r = Record::new(
Kind::ChatRequest,
sample_payload(),
"2026-04-21T10:00:00Z",
None,
);
assert_eq!(r.id, hash::content_id(&sample_payload()));
assert_eq!(r.version, "0.1");
assert_eq!(r.kind, Kind::ChatRequest);
assert_eq!(r.parent, None);
assert!(r.meta.is_none());
}
#[test]
fn verify_id_is_true_for_untampered_record() {
let r = Record::new(
Kind::Metadata,
json!({"sdk":{"name":"shadow","version":"0.1.0"}}),
"2026-04-21T10:00:00Z",
None,
);
assert!(r.verify_id());
}
#[test]
fn verify_id_is_false_if_payload_tampered() {
let mut r = Record::new(
Kind::ChatRequest,
sample_payload(),
"2026-04-21T10:00:00Z",
None,
);
r.payload = json!({ "model": "different" });
assert!(!r.verify_id());
}
#[test]
fn kind_serializes_snake_case() {
let json = serde_json::to_string(&Kind::ChatRequest).unwrap();
assert_eq!(json, r#""chat_request""#);
let kind: Kind = serde_json::from_str(r#""replay_summary""#).unwrap();
assert_eq!(kind, Kind::ReplaySummary);
}
#[test]
fn all_kinds_roundtrip() {
for kind in [
Kind::Metadata,
Kind::ChatRequest,
Kind::ChatResponse,
Kind::ToolCall,
Kind::ToolResult,
Kind::Error,
Kind::ReplaySummary,
Kind::Chunk,
Kind::HarnessEvent,
Kind::BlobRef,
] {
let s = serde_json::to_string(&kind).unwrap();
let back: Kind = serde_json::from_str(&s).unwrap();
assert_eq!(kind, back);
}
}
#[test]
fn record_roundtrips_through_serde_json() {
let original = Record::new(
Kind::ChatRequest,
sample_payload(),
"2026-04-21T10:00:00.100Z",
Some("sha256:abc".to_string()),
);
let wire = serde_json::to_string(&original).unwrap();
let back: Record = serde_json::from_str(&wire).unwrap();
assert_eq!(original, back);
}
#[test]
fn meta_is_omitted_when_none() {
let r = Record::new(
Kind::ChatRequest,
json!({"model": "x"}),
"2026-04-21T10:00:00Z",
None,
);
let wire = serde_json::to_string(&r).unwrap();
assert!(!wire.contains(r#""meta""#), "wire = {wire}");
}
#[test]
fn meta_survives_roundtrip_when_set() {
let mut meta = Map::new();
meta.insert("session_tag".to_string(), json!("prod-agent-0"));
let r = Record::new(Kind::Metadata, json!({}), "2026-04-21T10:00:00Z", None)
.with_meta(meta.clone());
let wire = serde_json::to_string(&r).unwrap();
let back: Record = serde_json::from_str(&wire).unwrap();
assert_eq!(back.meta, Some(meta));
}
#[test]
fn new_is_independent_of_provided_ts_and_parent() {
let p = sample_payload();
let a = Record::new(Kind::ChatRequest, p.clone(), "2026-04-21T10:00:00Z", None);
let b = Record::new(
Kind::ChatRequest,
p.clone(),
"2026-12-31T23:59:59Z",
Some("sha256:parent".to_string()),
);
assert_eq!(a.id, b.id);
}
}