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    /// Tool usage summary: declared vs actual tools used during the session.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub tool_usage: Option<ToolUsage>,
46}
47
48/// Tool authorization and usage summary for the session.
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct ToolUsage {
51    /// Tools declared as authorized (from declaration.json).
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub declared: Vec<String>,
54    /// Tools actually called during the session with invocation counts.
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    pub actual: Vec<ToolUsageEntry>,
57    /// Tools called that were NOT in the declared list.
58    #[serde(default, skip_serializing_if = "Vec::is_empty")]
59    pub unauthorized: Vec<String>,
60}
61
62/// A single tool's usage count.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ToolUsageEntry {
65    pub tool_name: String,
66    pub count: u32,
67}
68
69/// Session metadata section of the receipt.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct SessionSection {
72    pub id: String,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub name: Option<String>,
75    pub mode: LifecycleMode,
76    pub started_at: String,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub ended_at: Option<String>,
79    pub status: SessionStatus,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub duration_ms: Option<u64>,
82    /// Structured narrative for human review. All fields optional.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub narrative: Option<Narrative>,
85    /// Cumulative cost in USD across all agents.
86    #[serde(default, skip_serializing_if = "is_zero_f64")]
87    pub total_cost_usd: f64,
88    /// Cumulative input tokens across all agents.
89    #[serde(default)]
90    pub total_tokens_in: u64,
91    /// Cumulative output tokens across all agents.
92    #[serde(default)]
93    pub total_tokens_out: u64,
94}
95
96fn is_zero_f64(v: &f64) -> bool { *v == 0.0 }
97
98/// Structured narrative for the session summary.
99#[derive(Debug, Clone, Default, Serialize, Deserialize)]
100pub struct Narrative {
101    /// One-line headline: "Verifier refactor completed."
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub headline: Option<String>,
104    /// Multi-sentence summary of what happened.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub summary: Option<String>,
107    /// What should be reviewed before trusting the output.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub review: Option<String>,
110}
111
112/// A single timeline entry.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct TimelineEntry {
115    pub sequence_no: u64,
116    pub timestamp: String,
117    pub event_id: String,
118    pub event_type: String,
119    pub agent_instance_id: String,
120    pub agent_name: String,
121    pub host_id: String,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub summary: Option<String>,
124}
125
126/// An artifact referenced in the session.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct ArtifactEntry {
129    pub artifact_id: String,
130    pub payload_type: String,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub digest: Option<String>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub signed_at: Option<String>,
135}
136
137/// Proofs section of the receipt.
138#[derive(Debug, Clone, Default, Serialize, Deserialize)]
139pub struct ProofsSection {
140    #[serde(default)]
141    pub signature_count: u32,
142    #[serde(default)]
143    pub signatures_valid: bool,
144    #[serde(default)]
145    pub merkle_root_valid: bool,
146    #[serde(default)]
147    pub inclusion_proofs_count: u32,
148    #[serde(default)]
149    pub zk_proofs_present: bool,
150}
151
152/// Merkle section of the receipt.
153#[derive(Debug, Clone, Default, Serialize, Deserialize)]
154pub struct MerkleSection {
155    pub leaf_count: usize,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub root: Option<String>,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub checkpoint_id: Option<String>,
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub inclusion_proofs: Vec<InclusionProofEntry>,
162}
163
164/// A Merkle inclusion proof entry.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct InclusionProofEntry {
167    pub artifact_id: String,
168    pub leaf_index: usize,
169    pub proof: InclusionProof,
170}
171
172// ── Composer ─────────────────────────────────────────────────────────
173
174/// Composes a Session Receipt from events and artifacts.
175pub struct ReceiptComposer;
176
177impl ReceiptComposer {
178    /// Compose a receipt from a session manifest, events, and optional artifact entries.
179    pub fn compose(
180        manifest: &SessionManifest,
181        events: &[SessionEvent],
182        artifact_entries: Vec<ArtifactEntry>,
183    ) -> SessionReceipt {
184        // Build agent graph
185        let agent_graph = AgentGraph::from_events(events);
186
187        // Build side effects
188        let side_effects = SideEffects::from_events(events);
189
190        // Build timeline from all events
191        let mut timeline: Vec<TimelineEntry> = events.iter().map(|e| {
192            TimelineEntry {
193                sequence_no: e.sequence_no,
194                timestamp: e.timestamp.clone(),
195                event_id: e.event_id.clone(),
196                event_type: event_type_label(&e.event_type),
197                agent_instance_id: e.agent_instance_id.clone(),
198                agent_name: e.agent_name.clone(),
199                host_id: e.host_id.clone(),
200                summary: event_summary(&e.event_type),
201            }
202        }).collect();
203
204        // Sort by (timestamp, sequence_no, event_id) for determinism
205        timeline.sort_by(|a, b| {
206            a.timestamp.cmp(&b.timestamp)
207                .then(a.sequence_no.cmp(&b.sequence_no))
208                .then(a.event_id.cmp(&b.event_id))
209        });
210
211        // Compute participants from graph
212        let participants = compute_participants(&agent_graph, manifest);
213
214        // Compute hosts and tools from events
215        let hosts = compute_hosts(events, &manifest.hosts);
216        let tools = compute_tools(events, &manifest.tools);
217
218        // Compute duration from the session close event if present
219        let duration_ms = events.iter().find_map(|e| {
220            if let super::event::EventType::SessionClosed { duration_ms, .. } = &e.event_type {
221                *duration_ms
222            } else {
223                None
224            }
225        });
226
227        // Build Merkle tree from artifact IDs
228        let (merkle_section, merkle_tree) = build_merkle(&artifact_entries);
229
230        // Proofs section. zk_proofs_present defaults to false here;
231        // the CLI caller sets it to true after compose if proof files
232        // exist in the session directory.
233        let proofs = ProofsSection {
234            signature_count: artifact_entries.len() as u32,
235            signatures_valid: true, // Caller should verify
236            merkle_root_valid: merkle_tree.is_some(),
237            inclusion_proofs_count: merkle_section.inclusion_proofs.len() as u32,
238            zk_proofs_present: false,
239        };
240
241        // Compute cost/token totals from agent graph
242        let total_cost_usd: f64 = agent_graph.nodes.iter().map(|n| n.cost_usd).sum();
243        let total_tokens_in: u64 = agent_graph.nodes.iter().map(|n| n.tokens_in).sum();
244        let total_tokens_out: u64 = agent_graph.nodes.iter().map(|n| n.tokens_out).sum();
245
246        // Session section
247        let session = SessionSection {
248            id: manifest.session_id.clone(),
249            name: manifest.name.clone(),
250            mode: manifest.mode.clone(),
251            started_at: manifest.started_at.clone(),
252            ended_at: manifest.closed_at.clone(),
253            status: manifest.status.clone(),
254            duration_ms,
255            narrative: manifest.summary.as_ref().map(|s| Narrative {
256                headline: manifest.name.clone(),
257                summary: Some(s.clone()),
258                review: None,
259            }),
260            total_cost_usd,
261            total_tokens_in,
262            total_tokens_out,
263        };
264
265        // Render config
266        let render = RenderConfig {
267            title: manifest.name.clone(),
268            theme: None,
269            sections: RenderConfig::default_sections(),
270            generate_preview: true,
271        };
272
273        // Derive tool usage from side effects + manifest authorized_tools
274        let tool_usage = derive_tool_usage(&side_effects, &manifest.authorized_tools);
275
276        SessionReceipt {
277            type_: RECEIPT_TYPE.into(),
278            session,
279            participants,
280            hosts,
281            tools,
282            agent_graph,
283            timeline,
284            side_effects,
285            artifacts: artifact_entries,
286            proofs,
287            merkle: merkle_section,
288            render,
289            tool_usage,
290        }
291    }
292
293    /// Produce deterministic canonical JSON bytes from a receipt.
294    ///
295    /// Uses serde's field-declaration-order serialization for determinism.
296    /// The resulting bytes are suitable for hashing.
297    pub fn to_canonical_json(receipt: &SessionReceipt) -> Result<Vec<u8>, serde_json::Error> {
298        serde_json::to_vec(receipt)
299    }
300
301    /// Compute SHA-256 digest of the canonical receipt JSON.
302    pub fn digest(receipt: &SessionReceipt) -> Result<String, serde_json::Error> {
303        let bytes = Self::to_canonical_json(receipt)?;
304        let hash = Sha256::digest(&bytes);
305        Ok(format!("sha256:{}", hex::encode(hash)))
306    }
307}
308
309// ── Helpers ──────────────────────────────────────────────────────────
310
311fn compute_participants(graph: &AgentGraph, manifest: &SessionManifest) -> Participants {
312    use std::collections::BTreeSet;
313
314    let mut tool_runtimes: BTreeSet<String> = BTreeSet::new();
315    // Count unique agents
316    let total_agents = graph.nodes.len() as u32;
317    let spawned_subagents = graph.spawn_count();
318    let handoffs = graph.handoff_count();
319    let max_depth = graph.max_depth();
320    let host_ids = graph.host_ids();
321
322    // Collect tool runtimes from events in manifest
323    for tool in &manifest.tools {
324        if let Some(ref rt) = tool.tool_runtime_id {
325            tool_runtimes.insert(rt.clone());
326        }
327    }
328
329    // Find root agent (depth 0, first started)
330    let root = graph.nodes.iter()
331        .filter(|n| n.depth == 0)
332        .min_by_key(|n| n.started_at.as_deref().unwrap_or(""))
333        .map(|n| n.agent_instance_id.clone());
334
335    // Find final output agent (last completed at max depth or last completed overall)
336    let final_output = graph.nodes.iter()
337        .filter(|n| n.completed_at.is_some())
338        .max_by_key(|n| n.completed_at.as_deref().unwrap_or(""))
339        .map(|n| n.agent_instance_id.clone());
340
341    Participants {
342        root_agent_instance_id: root.or(manifest.participants.root_agent_instance_id.clone()),
343        final_output_agent_instance_id: final_output.or(manifest.participants.final_output_agent_instance_id.clone()),
344        total_agents,
345        spawned_subagents,
346        handoffs,
347        max_depth,
348        hosts: host_ids.len() as u32,
349        tool_runtimes: tool_runtimes.len() as u32,
350    }
351}
352
353fn compute_hosts(events: &[SessionEvent], manifest_hosts: &[HostInfo]) -> Vec<HostInfo> {
354    use std::collections::BTreeMap;
355
356    let mut hosts: BTreeMap<String, HostInfo> = BTreeMap::new();
357
358    // Seed from manifest
359    for h in manifest_hosts {
360        hosts.insert(h.host_id.clone(), h.clone());
361    }
362
363    // Discover from events
364    for e in events {
365        hosts.entry(e.host_id.clone()).or_insert_with(|| HostInfo {
366            host_id: e.host_id.clone(),
367            hostname: None,
368            os: None,
369            arch: None,
370        });
371    }
372
373    hosts.into_values().collect()
374}
375
376fn compute_tools(events: &[SessionEvent], manifest_tools: &[ToolInfo]) -> Vec<ToolInfo> {
377    use std::collections::BTreeMap;
378
379    let mut tools: BTreeMap<String, ToolInfo> = BTreeMap::new();
380
381    // Seed from manifest
382    for t in manifest_tools {
383        tools.insert(t.tool_id.clone(), t.clone());
384    }
385
386    // Count tool invocations from events
387    for e in events {
388        if let super::event::EventType::AgentCalledTool { ref tool_name, .. } = e.event_type {
389            let entry = tools.entry(tool_name.clone()).or_insert_with(|| ToolInfo {
390                tool_id: tool_name.clone(),
391                tool_name: tool_name.clone(),
392                tool_runtime_id: e.tool_runtime_id.clone(),
393                invocation_count: 0,
394            });
395            entry.invocation_count += 1;
396        }
397    }
398
399    tools.into_values().collect()
400}
401
402fn build_merkle(artifacts: &[ArtifactEntry]) -> (MerkleSection, Option<MerkleTree>) {
403    if artifacts.is_empty() {
404        return (MerkleSection::default(), None);
405    }
406
407    let mut tree = MerkleTree::new();
408    for art in artifacts {
409        tree.append(&art.artifact_id);
410    }
411
412    let root = tree.root().map(|r| format!("mroot_{}", hex::encode(r)));
413
414    // Build inclusion proofs for each artifact
415    let inclusion_proofs: Vec<InclusionProofEntry> = artifacts.iter().enumerate()
416        .filter_map(|(i, art)| {
417            tree.inclusion_proof(i).map(|proof| InclusionProofEntry {
418                artifact_id: art.artifact_id.clone(),
419                leaf_index: i,
420                proof,
421            })
422        })
423        .collect();
424
425    let section = MerkleSection {
426        leaf_count: artifacts.len(),
427        root,
428        checkpoint_id: None,
429        inclusion_proofs,
430    };
431
432    (section, Some(tree))
433}
434
435/// Extract a human-readable label from an EventType.
436/// Derive tool usage from side effects and the declared authorized tools list.
437fn derive_tool_usage(
438    side_effects: &SideEffects,
439    authorized_tools: &[String],
440) -> Option<ToolUsage> {
441    use std::collections::BTreeMap;
442
443    if side_effects.tool_invocations.is_empty() && authorized_tools.is_empty() {
444        return None;
445    }
446
447    // Count actual tool usage
448    let mut counts: BTreeMap<String, u32> = BTreeMap::new();
449    for inv in &side_effects.tool_invocations {
450        *counts.entry(inv.tool_name.clone()).or_insert(0) += 1;
451    }
452
453    let actual: Vec<ToolUsageEntry> = counts.iter()
454        .map(|(name, &count)| ToolUsageEntry { tool_name: name.clone(), count })
455        .collect();
456
457    // Find tools called that were NOT in the declared list
458    let unauthorized = if authorized_tools.is_empty() {
459        Vec::new()
460    } else {
461        let declared_set: std::collections::BTreeSet<&str> = authorized_tools.iter()
462            .map(|s| s.as_str())
463            .collect();
464        counts.keys()
465            .filter(|name| !declared_set.contains(name.as_str()))
466            .cloned()
467            .collect()
468    };
469
470    Some(ToolUsage {
471        declared: authorized_tools.to_vec(),
472        actual,
473        unauthorized,
474    })
475}
476
477fn event_type_label(et: &super::event::EventType) -> String {
478    use super::event::EventType::*;
479    match et {
480        SessionStarted => "session.started",
481        SessionClosed { .. } => "session.closed",
482        AgentStarted { .. } => "agent.started",
483        AgentSpawned { .. } => "agent.spawned",
484        AgentHandoff { .. } => "agent.handoff",
485        AgentCollaborated { .. } => "agent.collaborated",
486        AgentReturned { .. } => "agent.returned",
487        AgentCompleted { .. } => "agent.completed",
488        AgentFailed { .. } => "agent.failed",
489        AgentCalledTool { .. } => "agent.called_tool",
490        AgentReadFile { .. } => "agent.read_file",
491        AgentWroteFile { .. } => "agent.wrote_file",
492        AgentOpenedPort { .. } => "agent.opened_port",
493        AgentConnectedNetwork { .. } => "agent.connected_network",
494        AgentStartedProcess { .. } => "agent.started_process",
495        AgentCompletedProcess { .. } => "agent.completed_process",
496        AgentDecision { .. } => "agent.decision",
497    }.into()
498}
499
500/// Optional human-readable summary from an EventType.
501fn event_summary(et: &super::event::EventType) -> Option<String> {
502    use super::event::EventType::*;
503    match et {
504        SessionStarted => Some("Session started".into()),
505        SessionClosed { summary, .. } => summary.clone().or(Some("Session closed".into())),
506        AgentSpawned { reason, .. } => reason.clone(),
507        AgentHandoff { from_agent_instance_id, to_agent_instance_id, .. } => {
508            Some(format!("{from_agent_instance_id} -> {to_agent_instance_id}"))
509        }
510        AgentCalledTool { tool_name, .. } => Some(format!("Called {tool_name}")),
511        AgentReadFile { file_path, .. } => Some(format!("Read {file_path}")),
512        AgentWroteFile { file_path, .. } => Some(format!("Wrote {file_path}")),
513        AgentOpenedPort { port, .. } => Some(format!("Opened port {port}")),
514        AgentConnectedNetwork { destination, .. } => Some(format!("Connected to {destination}")),
515        AgentStartedProcess { process_name, .. } => Some(format!("Started {process_name}")),
516        AgentCompletedProcess { process_name, exit_code, .. } => {
517            Some(format!("Completed {process_name} (exit {})", exit_code.unwrap_or(-1)))
518        }
519        AgentCompleted { termination_reason } => termination_reason.clone().or(Some("Agent completed".into())),
520        AgentFailed { reason } => reason.clone().or(Some("Agent failed".into())),
521        AgentDecision { model, summary, cost_usd, .. } => {
522            let mut parts = Vec::new();
523            if let Some(s) = summary { parts.push(s.clone()); }
524            if let Some(m) = model { parts.push(format!("model: {m}")); }
525            if let Some(c) = cost_usd { parts.push(format!("${:.2}", c)); }
526            if parts.is_empty() { Some("LLM decision".into()) } else { Some(parts.join(" | ")) }
527        }
528        _ => None,
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::session::event::*;
536
537    fn make_manifest() -> SessionManifest {
538        SessionManifest::new(
539            "ssn_001".into(),
540            "agent://test".into(),
541            "2026-04-05T08:00:00Z".into(),
542            1743843600000,
543        )
544    }
545
546    fn make_events() -> Vec<SessionEvent> {
547        let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
548            SessionEvent {
549                session_id: "ssn_001".into(),
550                event_id: format!("evt_{:016x}", seq),
551                timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
552                sequence_no: seq,
553                trace_id: "trace_1".into(),
554                span_id: format!("span_{seq}"),
555                parent_span_id: None,
556                agent_id: format!("agent://{inst}"),
557                agent_instance_id: inst.into(),
558                agent_name: inst.into(),
559                agent_role: None,
560                host_id: "host_1".into(),
561                tool_runtime_id: None,
562                event_type: et,
563                artifact_ref: None,
564                meta: None,
565            }
566        };
567
568        vec![
569            mk(0, "root", EventType::SessionStarted),
570            mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
571            mk(2, "worker", EventType::AgentSpawned { spawned_by_agent_instance_id: "root".into(), reason: Some("review".into()) }),
572            mk(3, "worker", EventType::AgentCalledTool { tool_name: "read_file".into(), tool_input_digest: None, tool_output_digest: None, duration_ms: Some(5) }),
573            mk(4, "worker", EventType::AgentWroteFile { file_path: "src/fix.rs".into(), digest: None, operation: None, additions: None, deletions: None }),
574            mk(5, "worker", EventType::AgentCompleted { termination_reason: None }),
575            mk(6, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(360000) }),
576        ]
577    }
578
579    #[test]
580    fn compose_receipt() {
581        let manifest = make_manifest();
582        let events = make_events();
583        let artifacts = vec![
584            ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
585            ArtifactEntry { artifact_id: "art_002".into(), payload_type: "action".into(), digest: None, signed_at: None },
586        ];
587
588        let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
589
590        assert_eq!(receipt.type_, RECEIPT_TYPE);
591        assert_eq!(receipt.session.id, "ssn_001");
592        assert_eq!(receipt.timeline.len(), 7);
593        assert_eq!(receipt.agent_graph.nodes.len(), 2); // root + worker
594        assert_eq!(receipt.side_effects.files_written.len(), 1);
595        assert_eq!(receipt.merkle.leaf_count, 2);
596        assert!(receipt.merkle.root.is_some());
597    }
598
599    #[test]
600    fn canonical_json_is_deterministic() {
601        let manifest = make_manifest();
602        let events = make_events();
603        let artifacts = vec![
604            ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
605        ];
606
607        let r1 = ReceiptComposer::compose(&manifest, &events, artifacts.clone());
608        let r2 = ReceiptComposer::compose(&manifest, &events, artifacts);
609
610        let j1 = ReceiptComposer::to_canonical_json(&r1).unwrap();
611        let j2 = ReceiptComposer::to_canonical_json(&r2).unwrap();
612        assert_eq!(j1, j2);
613
614        let d1 = ReceiptComposer::digest(&r1).unwrap();
615        let d2 = ReceiptComposer::digest(&r2).unwrap();
616        assert_eq!(d1, d2);
617    }
618}