Skip to main content

shadow_core/agentlog/
record.rs

1//! Record envelope types (SPEC §3 + §4).
2//!
3//! The envelope is statically typed; the payload is held as a
4//! [`serde_json::Value`] at this layer. Typed payload structs (`ChatRequest`,
5//! `ChatResponse`, etc.) are added alongside the modules that consume them
6//! (replay, diff) — they're convenience wrappers over `payload.into_typed()`
7//! rather than the wire format.
8
9use serde::{Deserialize, Serialize};
10use serde_json::{Map, Value};
11
12use crate::agentlog::hash;
13
14/// The current `.agentlog` schema version (SPEC §1, §13).
15pub const CURRENT_VERSION: &str = "0.1";
16
17/// One record in an `.agentlog` file (SPEC §3.1).
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct Record {
20    /// `.agentlog` schema version; must equal [`CURRENT_VERSION`] for records
21    /// created by this implementation. Strict parsers reject other values.
22    pub version: String,
23
24    /// Content id: `"sha256:" + hex(sha256(canonical_json(payload)))` (SPEC §6).
25    pub id: String,
26
27    /// Record kind (SPEC §4).
28    pub kind: Kind,
29
30    /// RFC 3339 UTC timestamp with millisecond precision, e.g. `"2026-04-21T10:00:00.100Z"`.
31    /// Always ends in `Z` (no numeric offset) — SPEC §3.1.
32    pub ts: String,
33
34    /// Parent record id, or `None` if this is the root (`metadata`) record.
35    pub parent: Option<String>,
36
37    /// Free-form envelope metadata. Not part of the content hash.
38    #[serde(skip_serializing_if = "Option::is_none", default)]
39    pub meta: Option<Map<String, Value>>,
40
41    /// The kind-specific body; SHA-256 of the canonical form of this field
42    /// is the `id`.
43    pub payload: Value,
44}
45
46/// Record kind discriminator (SPEC §4).
47///
48/// Serializes as lowercase `snake_case` to match the JSON wire format.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum Kind {
52    /// Trace-level metadata; always first record, `parent: null` (SPEC §4.5).
53    Metadata,
54    /// Request sent to an LLM (SPEC §4.1).
55    ChatRequest,
56    /// Response from an LLM (SPEC §4.2).
57    ChatResponse,
58    /// Agent-side tool dispatch (SPEC §4.3).
59    ToolCall,
60    /// Tool execution result (SPEC §4.4).
61    ToolResult,
62    /// Error event (SPEC §4.6).
63    Error,
64    /// End-of-replay summary (SPEC §4.7).
65    ReplaySummary,
66    /// Single streaming-LLM chunk (SPEC §4.8, v0.2).
67    Chunk,
68    /// Framework-level harness event (SPEC §4.9, v0.2).
69    HarnessEvent,
70    /// Content-addressed blob reference (SPEC §4.10, v0.2).
71    BlobRef,
72}
73
74impl Record {
75    /// Build a new record, computing `id` from the canonical-JSON of `payload`.
76    ///
77    /// The `version` field is always set to [`CURRENT_VERSION`]; the caller
78    /// provides `kind`, `payload`, `ts`, and `parent`. `meta` is `None` — set
79    /// it via [`Record::with_meta`] if needed.
80    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    /// Attach a `meta` map (builder-style).
94    pub fn with_meta(mut self, meta: Map<String, Value>) -> Self {
95        self.meta = Some(meta);
96        self
97    }
98
99    /// Re-compute the id from the payload and compare. Returns `true` if the
100    /// record has not been tampered with since it was created.
101    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        // Tamper: swap the payload without recomputing id.
150        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        // Use Kind::ChatRequest to avoid the substring "metadata" from the kind
198        // confusing a naive check against the field name "meta".
199        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        // Two records with the same payload but different ts/parent MUST
223        // have the same id (payload-only hashing, SPEC §6.1).
224        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}