Skip to main content

treeship_core/session/
receipt.rs

1//! Session Receipt composer.
2//!
3//! Builds the canonical Session Receipt JSON from session events,
4//! artifact store, and Merkle tree. The receipt is the composed
5//! package-level artifact that unifies an entire session.
6
7use serde::{Deserialize, Serialize};
8use sha2::{Sha256, Digest};
9
10use crate::merkle::{MerkleTree, InclusionProof};
11
12use super::event::SessionEvent;
13use super::graph::AgentGraph;
14use super::manifest::{
15    HostInfo, LifecycleMode, Participants, SessionManifest, SessionStatus, ToolInfo,
16};
17use super::render::RenderConfig;
18use super::side_effects::SideEffects;
19
20/// Receipt type identifier.
21pub const RECEIPT_TYPE: &str = "treeship/session-receipt/v1";
22
23// ── Top-level receipt ────────────────────────────────────────────────
24
25/// The complete Session Receipt.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SessionReceipt {
28    /// Always "treeship/session-receipt/v1".
29    #[serde(rename = "type")]
30    pub type_: String,
31
32    pub session: SessionSection,
33    pub participants: Participants,
34    pub hosts: Vec<HostInfo>,
35    pub tools: Vec<ToolInfo>,
36    pub agent_graph: AgentGraph,
37    pub timeline: Vec<TimelineEntry>,
38    pub side_effects: SideEffects,
39    pub artifacts: Vec<ArtifactEntry>,
40    pub proofs: ProofsSection,
41    pub merkle: MerkleSection,
42    pub render: RenderConfig,
43}
44
45/// Session metadata section of the receipt.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct SessionSection {
48    pub id: String,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub name: Option<String>,
51    pub mode: LifecycleMode,
52    pub started_at: String,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub ended_at: Option<String>,
55    pub status: SessionStatus,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub duration_ms: Option<u64>,
58}
59
60/// A single timeline entry.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TimelineEntry {
63    pub sequence_no: u64,
64    pub timestamp: String,
65    pub event_id: String,
66    pub event_type: String,
67    pub agent_instance_id: String,
68    pub agent_name: String,
69    pub host_id: String,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub summary: Option<String>,
72}
73
74/// An artifact referenced in the session.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ArtifactEntry {
77    pub artifact_id: String,
78    pub payload_type: String,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub digest: Option<String>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub signed_at: Option<String>,
83}
84
85/// Proofs section of the receipt.
86#[derive(Debug, Clone, Default, Serialize, Deserialize)]
87pub struct ProofsSection {
88    #[serde(default)]
89    pub signature_count: u32,
90    #[serde(default)]
91    pub signatures_valid: bool,
92    #[serde(default)]
93    pub merkle_root_valid: bool,
94    #[serde(default)]
95    pub inclusion_proofs_count: u32,
96    #[serde(default)]
97    pub zk_proofs_present: bool,
98}
99
100/// Merkle section of the receipt.
101#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102pub struct MerkleSection {
103    pub leaf_count: usize,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub root: Option<String>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub checkpoint_id: Option<String>,
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub inclusion_proofs: Vec<InclusionProofEntry>,
110}
111
112/// A Merkle inclusion proof entry.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct InclusionProofEntry {
115    pub artifact_id: String,
116    pub leaf_index: usize,
117    pub proof: InclusionProof,
118}
119
120// ── Composer ─────────────────────────────────────────────────────────
121
122/// Composes a Session Receipt from events and artifacts.
123pub struct ReceiptComposer;
124
125impl ReceiptComposer {
126    /// Compose a receipt from a session manifest, events, and optional artifact entries.
127    pub fn compose(
128        manifest: &SessionManifest,
129        events: &[SessionEvent],
130        artifact_entries: Vec<ArtifactEntry>,
131    ) -> SessionReceipt {
132        // Build agent graph
133        let agent_graph = AgentGraph::from_events(events);
134
135        // Build side effects
136        let side_effects = SideEffects::from_events(events);
137
138        // Build timeline from all events
139        let mut timeline: Vec<TimelineEntry> = events.iter().map(|e| {
140            TimelineEntry {
141                sequence_no: e.sequence_no,
142                timestamp: e.timestamp.clone(),
143                event_id: e.event_id.clone(),
144                event_type: event_type_label(&e.event_type),
145                agent_instance_id: e.agent_instance_id.clone(),
146                agent_name: e.agent_name.clone(),
147                host_id: e.host_id.clone(),
148                summary: event_summary(&e.event_type),
149            }
150        }).collect();
151
152        // Sort by (timestamp, sequence_no, event_id) for determinism
153        timeline.sort_by(|a, b| {
154            a.timestamp.cmp(&b.timestamp)
155                .then(a.sequence_no.cmp(&b.sequence_no))
156                .then(a.event_id.cmp(&b.event_id))
157        });
158
159        // Compute participants from graph
160        let participants = compute_participants(&agent_graph, manifest);
161
162        // Compute hosts and tools from events
163        let hosts = compute_hosts(events, &manifest.hosts);
164        let tools = compute_tools(events, &manifest.tools);
165
166        // Compute duration from the session close event if present
167        let duration_ms = events.iter().find_map(|e| {
168            if let super::event::EventType::SessionClosed { duration_ms, .. } = &e.event_type {
169                *duration_ms
170            } else {
171                None
172            }
173        });
174
175        // Build Merkle tree from artifact IDs
176        let (merkle_section, merkle_tree) = build_merkle(&artifact_entries);
177
178        // Proofs section
179        let proofs = ProofsSection {
180            signature_count: artifact_entries.len() as u32,
181            signatures_valid: true, // Caller should verify
182            merkle_root_valid: merkle_tree.is_some(),
183            inclusion_proofs_count: merkle_section.inclusion_proofs.len() as u32,
184            zk_proofs_present: false,
185        };
186
187        // Session section
188        let session = SessionSection {
189            id: manifest.session_id.clone(),
190            name: manifest.name.clone(),
191            mode: manifest.mode.clone(),
192            started_at: manifest.started_at.clone(),
193            ended_at: manifest.closed_at.clone(),
194            status: manifest.status.clone(),
195            duration_ms,
196        };
197
198        // Render config
199        let render = RenderConfig {
200            title: manifest.name.clone(),
201            theme: None,
202            sections: RenderConfig::default_sections(),
203            generate_preview: true,
204        };
205
206        SessionReceipt {
207            type_: RECEIPT_TYPE.into(),
208            session,
209            participants,
210            hosts,
211            tools,
212            agent_graph,
213            timeline,
214            side_effects,
215            artifacts: artifact_entries,
216            proofs,
217            merkle: merkle_section,
218            render,
219        }
220    }
221
222    /// Produce deterministic canonical JSON bytes from a receipt.
223    ///
224    /// Uses serde's field-declaration-order serialization for determinism.
225    /// The resulting bytes are suitable for hashing.
226    pub fn to_canonical_json(receipt: &SessionReceipt) -> Result<Vec<u8>, serde_json::Error> {
227        serde_json::to_vec(receipt)
228    }
229
230    /// Compute SHA-256 digest of the canonical receipt JSON.
231    pub fn digest(receipt: &SessionReceipt) -> Result<String, serde_json::Error> {
232        let bytes = Self::to_canonical_json(receipt)?;
233        let hash = Sha256::digest(&bytes);
234        Ok(format!("sha256:{}", hex::encode(hash)))
235    }
236}
237
238// ── Helpers ──────────────────────────────────────────────────────────
239
240fn compute_participants(graph: &AgentGraph, manifest: &SessionManifest) -> Participants {
241    use std::collections::BTreeSet;
242
243    let mut tool_runtimes: BTreeSet<String> = BTreeSet::new();
244    // Count unique agents
245    let total_agents = graph.nodes.len() as u32;
246    let spawned_subagents = graph.spawn_count();
247    let handoffs = graph.handoff_count();
248    let max_depth = graph.max_depth();
249    let host_ids = graph.host_ids();
250
251    // Collect tool runtimes from events in manifest
252    for tool in &manifest.tools {
253        if let Some(ref rt) = tool.tool_runtime_id {
254            tool_runtimes.insert(rt.clone());
255        }
256    }
257
258    // Find root agent (depth 0, first started)
259    let root = graph.nodes.iter()
260        .filter(|n| n.depth == 0)
261        .min_by_key(|n| n.started_at.as_deref().unwrap_or(""))
262        .map(|n| n.agent_instance_id.clone());
263
264    // Find final output agent (last completed at max depth or last completed overall)
265    let final_output = graph.nodes.iter()
266        .filter(|n| n.completed_at.is_some())
267        .max_by_key(|n| n.completed_at.as_deref().unwrap_or(""))
268        .map(|n| n.agent_instance_id.clone());
269
270    Participants {
271        root_agent_instance_id: root.or(manifest.participants.root_agent_instance_id.clone()),
272        final_output_agent_instance_id: final_output.or(manifest.participants.final_output_agent_instance_id.clone()),
273        total_agents,
274        spawned_subagents,
275        handoffs,
276        max_depth,
277        hosts: host_ids.len() as u32,
278        tool_runtimes: tool_runtimes.len() as u32,
279    }
280}
281
282fn compute_hosts(events: &[SessionEvent], manifest_hosts: &[HostInfo]) -> Vec<HostInfo> {
283    use std::collections::BTreeMap;
284
285    let mut hosts: BTreeMap<String, HostInfo> = BTreeMap::new();
286
287    // Seed from manifest
288    for h in manifest_hosts {
289        hosts.insert(h.host_id.clone(), h.clone());
290    }
291
292    // Discover from events
293    for e in events {
294        hosts.entry(e.host_id.clone()).or_insert_with(|| HostInfo {
295            host_id: e.host_id.clone(),
296            hostname: None,
297            os: None,
298            arch: None,
299        });
300    }
301
302    hosts.into_values().collect()
303}
304
305fn compute_tools(events: &[SessionEvent], manifest_tools: &[ToolInfo]) -> Vec<ToolInfo> {
306    use std::collections::BTreeMap;
307
308    let mut tools: BTreeMap<String, ToolInfo> = BTreeMap::new();
309
310    // Seed from manifest
311    for t in manifest_tools {
312        tools.insert(t.tool_id.clone(), t.clone());
313    }
314
315    // Count tool invocations from events
316    for e in events {
317        if let super::event::EventType::AgentCalledTool { ref tool_name, .. } = e.event_type {
318            let entry = tools.entry(tool_name.clone()).or_insert_with(|| ToolInfo {
319                tool_id: tool_name.clone(),
320                tool_name: tool_name.clone(),
321                tool_runtime_id: e.tool_runtime_id.clone(),
322                invocation_count: 0,
323            });
324            entry.invocation_count += 1;
325        }
326    }
327
328    tools.into_values().collect()
329}
330
331fn build_merkle(artifacts: &[ArtifactEntry]) -> (MerkleSection, Option<MerkleTree>) {
332    if artifacts.is_empty() {
333        return (MerkleSection::default(), None);
334    }
335
336    let mut tree = MerkleTree::new();
337    for art in artifacts {
338        tree.append(&art.artifact_id);
339    }
340
341    let root = tree.root().map(|r| format!("mroot_{}", &hex::encode(r)[..16]));
342
343    // Build inclusion proofs for each artifact
344    let inclusion_proofs: Vec<InclusionProofEntry> = artifacts.iter().enumerate()
345        .filter_map(|(i, art)| {
346            tree.inclusion_proof(i).map(|proof| InclusionProofEntry {
347                artifact_id: art.artifact_id.clone(),
348                leaf_index: i,
349                proof,
350            })
351        })
352        .collect();
353
354    let section = MerkleSection {
355        leaf_count: artifacts.len(),
356        root,
357        checkpoint_id: None,
358        inclusion_proofs,
359    };
360
361    (section, Some(tree))
362}
363
364/// Extract a human-readable label from an EventType.
365fn event_type_label(et: &super::event::EventType) -> String {
366    use super::event::EventType::*;
367    match et {
368        SessionStarted => "session.started",
369        SessionClosed { .. } => "session.closed",
370        AgentStarted { .. } => "agent.started",
371        AgentSpawned { .. } => "agent.spawned",
372        AgentHandoff { .. } => "agent.handoff",
373        AgentCollaborated { .. } => "agent.collaborated",
374        AgentReturned { .. } => "agent.returned",
375        AgentCompleted { .. } => "agent.completed",
376        AgentFailed { .. } => "agent.failed",
377        AgentCalledTool { .. } => "agent.called_tool",
378        AgentReadFile { .. } => "agent.read_file",
379        AgentWroteFile { .. } => "agent.wrote_file",
380        AgentOpenedPort { .. } => "agent.opened_port",
381        AgentConnectedNetwork { .. } => "agent.connected_network",
382        AgentStartedProcess { .. } => "agent.started_process",
383        AgentCompletedProcess { .. } => "agent.completed_process",
384    }.into()
385}
386
387/// Optional human-readable summary from an EventType.
388fn event_summary(et: &super::event::EventType) -> Option<String> {
389    use super::event::EventType::*;
390    match et {
391        SessionStarted => Some("Session started".into()),
392        SessionClosed { summary, .. } => summary.clone().or(Some("Session closed".into())),
393        AgentSpawned { reason, .. } => reason.clone(),
394        AgentHandoff { from_agent_instance_id, to_agent_instance_id, .. } => {
395            Some(format!("{from_agent_instance_id} -> {to_agent_instance_id}"))
396        }
397        AgentCalledTool { tool_name, .. } => Some(format!("Called {tool_name}")),
398        AgentReadFile { file_path, .. } => Some(format!("Read {file_path}")),
399        AgentWroteFile { file_path, .. } => Some(format!("Wrote {file_path}")),
400        AgentOpenedPort { port, .. } => Some(format!("Opened port {port}")),
401        AgentConnectedNetwork { destination, .. } => Some(format!("Connected to {destination}")),
402        AgentStartedProcess { process_name, .. } => Some(format!("Started {process_name}")),
403        AgentCompletedProcess { process_name, exit_code, .. } => {
404            Some(format!("Completed {process_name} (exit {})", exit_code.unwrap_or(-1)))
405        }
406        AgentCompleted { termination_reason } => termination_reason.clone().or(Some("Agent completed".into())),
407        AgentFailed { reason } => reason.clone().or(Some("Agent failed".into())),
408        _ => None,
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use crate::session::event::*;
416
417    fn make_manifest() -> SessionManifest {
418        SessionManifest::new(
419            "ssn_001".into(),
420            "agent://test".into(),
421            "2026-04-05T08:00:00Z".into(),
422            1743843600000,
423        )
424    }
425
426    fn make_events() -> Vec<SessionEvent> {
427        let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
428            SessionEvent {
429                session_id: "ssn_001".into(),
430                event_id: format!("evt_{:016x}", seq),
431                timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
432                sequence_no: seq,
433                trace_id: "trace_1".into(),
434                span_id: format!("span_{seq}"),
435                parent_span_id: None,
436                agent_id: format!("agent://{inst}"),
437                agent_instance_id: inst.into(),
438                agent_name: inst.into(),
439                agent_role: None,
440                host_id: "host_1".into(),
441                tool_runtime_id: None,
442                event_type: et,
443                artifact_ref: None,
444                meta: None,
445            }
446        };
447
448        vec![
449            mk(0, "root", EventType::SessionStarted),
450            mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
451            mk(2, "worker", EventType::AgentSpawned { spawned_by_agent_instance_id: "root".into(), reason: Some("review".into()) }),
452            mk(3, "worker", EventType::AgentCalledTool { tool_name: "read_file".into(), tool_input_digest: None, tool_output_digest: None, duration_ms: Some(5) }),
453            mk(4, "worker", EventType::AgentWroteFile { file_path: "src/fix.rs".into(), digest: None }),
454            mk(5, "worker", EventType::AgentCompleted { termination_reason: None }),
455            mk(6, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(360000) }),
456        ]
457    }
458
459    #[test]
460    fn compose_receipt() {
461        let manifest = make_manifest();
462        let events = make_events();
463        let artifacts = vec![
464            ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
465            ArtifactEntry { artifact_id: "art_002".into(), payload_type: "action".into(), digest: None, signed_at: None },
466        ];
467
468        let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
469
470        assert_eq!(receipt.type_, RECEIPT_TYPE);
471        assert_eq!(receipt.session.id, "ssn_001");
472        assert_eq!(receipt.timeline.len(), 7);
473        assert_eq!(receipt.agent_graph.nodes.len(), 2); // root + worker
474        assert_eq!(receipt.side_effects.files_written.len(), 1);
475        assert_eq!(receipt.merkle.leaf_count, 2);
476        assert!(receipt.merkle.root.is_some());
477    }
478
479    #[test]
480    fn canonical_json_is_deterministic() {
481        let manifest = make_manifest();
482        let events = make_events();
483        let artifacts = vec![
484            ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
485        ];
486
487        let r1 = ReceiptComposer::compose(&manifest, &events, artifacts.clone());
488        let r2 = ReceiptComposer::compose(&manifest, &events, artifacts);
489
490        let j1 = ReceiptComposer::to_canonical_json(&r1).unwrap();
491        let j2 = ReceiptComposer::to_canonical_json(&r2).unwrap();
492        assert_eq!(j1, j2);
493
494        let d1 = ReceiptComposer::digest(&r1).unwrap();
495        let d2 = ReceiptComposer::digest(&r2).unwrap();
496        assert_eq!(d1, d2);
497    }
498}