1use serde::{Deserialize, Serialize};
10use serde_json::{Map, Value};
11
12use crate::agentlog::hash;
13
14pub const CURRENT_VERSION: &str = "0.1";
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct Record {
20 pub version: String,
23
24 pub id: String,
26
27 pub kind: Kind,
29
30 pub ts: String,
33
34 pub parent: Option<String>,
36
37 #[serde(skip_serializing_if = "Option::is_none", default)]
39 pub meta: Option<Map<String, Value>>,
40
41 pub payload: Value,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum Kind {
52 Metadata,
54 ChatRequest,
56 ChatResponse,
58 ToolCall,
60 ToolResult,
62 Error,
64 ReplaySummary,
66 Chunk,
68 HarnessEvent,
70 BlobRef,
72}
73
74impl Record {
75 pub fn new(kind: Kind, payload: Value, ts: impl Into<String>, parent: Option<String>) -> Self {
81 let id = hash::content_id(&payload);
82 Self {
83 version: CURRENT_VERSION.to_string(),
84 id,
85 kind,
86 ts: ts.into(),
87 parent,
88 meta: None,
89 payload,
90 }
91 }
92
93 pub fn with_meta(mut self, meta: Map<String, Value>) -> Self {
95 self.meta = Some(meta);
96 self
97 }
98
99 pub fn verify_id(&self) -> bool {
102 self.id == hash::content_id(&self.payload)
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use serde_json::json;
110
111 fn sample_payload() -> Value {
112 json!({ "model": "claude-opus-4-7", "messages": [] })
113 }
114
115 #[test]
116 fn new_computes_id_from_payload() {
117 let r = Record::new(
118 Kind::ChatRequest,
119 sample_payload(),
120 "2026-04-21T10:00:00Z",
121 None,
122 );
123 assert_eq!(r.id, hash::content_id(&sample_payload()));
124 assert_eq!(r.version, "0.1");
125 assert_eq!(r.kind, Kind::ChatRequest);
126 assert_eq!(r.parent, None);
127 assert!(r.meta.is_none());
128 }
129
130 #[test]
131 fn verify_id_is_true_for_untampered_record() {
132 let r = Record::new(
133 Kind::Metadata,
134 json!({"sdk":{"name":"shadow","version":"0.1.0"}}),
135 "2026-04-21T10:00:00Z",
136 None,
137 );
138 assert!(r.verify_id());
139 }
140
141 #[test]
142 fn verify_id_is_false_if_payload_tampered() {
143 let mut r = Record::new(
144 Kind::ChatRequest,
145 sample_payload(),
146 "2026-04-21T10:00:00Z",
147 None,
148 );
149 r.payload = json!({ "model": "different" });
151 assert!(!r.verify_id());
152 }
153
154 #[test]
155 fn kind_serializes_snake_case() {
156 let json = serde_json::to_string(&Kind::ChatRequest).unwrap();
157 assert_eq!(json, r#""chat_request""#);
158 let kind: Kind = serde_json::from_str(r#""replay_summary""#).unwrap();
159 assert_eq!(kind, Kind::ReplaySummary);
160 }
161
162 #[test]
163 fn all_kinds_roundtrip() {
164 for kind in [
165 Kind::Metadata,
166 Kind::ChatRequest,
167 Kind::ChatResponse,
168 Kind::ToolCall,
169 Kind::ToolResult,
170 Kind::Error,
171 Kind::ReplaySummary,
172 Kind::Chunk,
173 Kind::HarnessEvent,
174 Kind::BlobRef,
175 ] {
176 let s = serde_json::to_string(&kind).unwrap();
177 let back: Kind = serde_json::from_str(&s).unwrap();
178 assert_eq!(kind, back);
179 }
180 }
181
182 #[test]
183 fn record_roundtrips_through_serde_json() {
184 let original = Record::new(
185 Kind::ChatRequest,
186 sample_payload(),
187 "2026-04-21T10:00:00.100Z",
188 Some("sha256:abc".to_string()),
189 );
190 let wire = serde_json::to_string(&original).unwrap();
191 let back: Record = serde_json::from_str(&wire).unwrap();
192 assert_eq!(original, back);
193 }
194
195 #[test]
196 fn meta_is_omitted_when_none() {
197 let r = Record::new(
200 Kind::ChatRequest,
201 json!({"model": "x"}),
202 "2026-04-21T10:00:00Z",
203 None,
204 );
205 let wire = serde_json::to_string(&r).unwrap();
206 assert!(!wire.contains(r#""meta""#), "wire = {wire}");
207 }
208
209 #[test]
210 fn meta_survives_roundtrip_when_set() {
211 let mut meta = Map::new();
212 meta.insert("session_tag".to_string(), json!("prod-agent-0"));
213 let r = Record::new(Kind::Metadata, json!({}), "2026-04-21T10:00:00Z", None)
214 .with_meta(meta.clone());
215 let wire = serde_json::to_string(&r).unwrap();
216 let back: Record = serde_json::from_str(&wire).unwrap();
217 assert_eq!(back.meta, Some(meta));
218 }
219
220 #[test]
221 fn new_is_independent_of_provided_ts_and_parent() {
222 let p = sample_payload();
225 let a = Record::new(Kind::ChatRequest, p.clone(), "2026-04-21T10:00:00Z", None);
226 let b = Record::new(
227 Kind::ChatRequest,
228 p.clone(),
229 "2026-12-31T23:59:59Z",
230 Some("sha256:parent".to_string()),
231 );
232 assert_eq!(a.id, b.id);
233 }
234}