1use 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
20pub const RECEIPT_TYPE: &str = "treeship/session-receipt/v1";
22
23pub const RECEIPT_SCHEMA_VERSION: &str = "1";
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct SessionReceipt {
32 #[serde(rename = "type")]
34 pub type_: String,
35
36 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub schema_version: Option<String>,
40
41 pub session: SessionSection,
42 pub participants: Participants,
43 pub hosts: Vec<HostInfo>,
44 pub tools: Vec<ToolInfo>,
45 pub agent_graph: AgentGraph,
46 pub timeline: Vec<TimelineEntry>,
47 pub side_effects: SideEffects,
48 pub artifacts: Vec<ArtifactEntry>,
49 pub proofs: ProofsSection,
50 pub merkle: MerkleSection,
51 pub render: RenderConfig,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub tool_usage: Option<ToolUsage>,
55}
56
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct ToolUsage {
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
62 pub declared: Vec<String>,
63 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub actual: Vec<ToolUsageEntry>,
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub unauthorized: Vec<String>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ToolUsageEntry {
74 pub tool_name: String,
75 pub count: u32,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct SessionSection {
81 pub id: String,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub name: Option<String>,
84 pub mode: LifecycleMode,
85 pub started_at: String,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub ended_at: Option<String>,
88 pub status: SessionStatus,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub duration_ms: Option<u64>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub ship_id: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub narrative: Option<Narrative>,
101 #[serde(default)]
103 pub total_tokens_in: u64,
104 #[serde(default)]
106 pub total_tokens_out: u64,
107}
108
109
110#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112pub struct Narrative {
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub headline: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub summary: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub review: Option<String>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct TimelineEntry {
127 pub sequence_no: u64,
128 pub timestamp: String,
129 pub event_id: String,
130 pub event_type: String,
131 pub agent_instance_id: String,
132 pub agent_name: String,
133 pub host_id: String,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub summary: Option<String>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct ArtifactEntry {
141 pub artifact_id: String,
142 pub payload_type: String,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub digest: Option<String>,
145 #[serde(skip_serializing_if = "Option::is_none")]
146 pub signed_at: Option<String>,
147}
148
149#[derive(Debug, Clone, Default, Serialize, Deserialize)]
151pub struct ProofsSection {
152 #[serde(default)]
153 pub signature_count: u32,
154 #[serde(default)]
155 pub signatures_valid: bool,
156 #[serde(default)]
157 pub merkle_root_valid: bool,
158 #[serde(default)]
159 pub inclusion_proofs_count: u32,
160 #[serde(default)]
161 pub zk_proofs_present: bool,
162}
163
164#[derive(Debug, Clone, Default, Serialize, Deserialize)]
166pub struct MerkleSection {
167 pub leaf_count: usize,
168 #[serde(skip_serializing_if = "Option::is_none")]
169 pub root: Option<String>,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub checkpoint_id: Option<String>,
172 #[serde(default, skip_serializing_if = "Vec::is_empty")]
173 pub inclusion_proofs: Vec<InclusionProofEntry>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct InclusionProofEntry {
179 pub artifact_id: String,
180 pub leaf_index: usize,
181 pub proof: InclusionProof,
182}
183
184pub struct ReceiptComposer;
188
189impl ReceiptComposer {
190 pub fn compose(
192 manifest: &SessionManifest,
193 events: &[SessionEvent],
194 artifact_entries: Vec<ArtifactEntry>,
195 ) -> SessionReceipt {
196 let agent_graph = AgentGraph::from_events(events);
198
199 let side_effects = SideEffects::from_events(events);
201
202 let mut timeline: Vec<TimelineEntry> = events.iter().map(|e| {
204 TimelineEntry {
205 sequence_no: e.sequence_no,
206 timestamp: e.timestamp.clone(),
207 event_id: e.event_id.clone(),
208 event_type: event_type_label(&e.event_type),
209 agent_instance_id: e.agent_instance_id.clone(),
210 agent_name: e.agent_name.clone(),
211 host_id: e.host_id.clone(),
212 summary: event_summary(&e.event_type),
213 }
214 }).collect();
215
216 timeline.sort_by(|a, b| {
218 a.timestamp.cmp(&b.timestamp)
219 .then(a.sequence_no.cmp(&b.sequence_no))
220 .then(a.event_id.cmp(&b.event_id))
221 });
222
223 let participants = compute_participants(&agent_graph, manifest);
225
226 let hosts = compute_hosts(events, &manifest.hosts);
228 let tools = compute_tools(events, &manifest.tools);
229
230 let duration_ms = events.iter().find_map(|e| {
232 if let super::event::EventType::SessionClosed { duration_ms, .. } = &e.event_type {
233 *duration_ms
234 } else {
235 None
236 }
237 });
238
239 let (merkle_section, merkle_tree) = build_merkle(&artifact_entries);
241
242 let proofs = ProofsSection {
246 signature_count: artifact_entries.len() as u32,
247 signatures_valid: true, merkle_root_valid: merkle_tree.is_some(),
249 inclusion_proofs_count: merkle_section.inclusion_proofs.len() as u32,
250 zk_proofs_present: false,
251 };
252
253 let total_tokens_in: u64 = agent_graph.nodes.iter().map(|n| n.tokens_in).sum();
256 let total_tokens_out: u64 = agent_graph.nodes.iter().map(|n| n.tokens_out).sum();
257
258 let session = SessionSection {
260 id: manifest.session_id.clone(),
261 name: manifest.name.clone(),
262 mode: manifest.mode.clone(),
263 started_at: manifest.started_at.clone(),
264 ended_at: manifest.closed_at.clone(),
265 status: manifest.status.clone(),
266 duration_ms,
267 ship_id: parse_ship_id_from_actor(&manifest.actor),
268 narrative: manifest.summary.as_ref().map(|s| Narrative {
269 headline: manifest.name.clone(),
270 summary: Some(s.clone()),
271 review: None,
272 }),
273 total_tokens_in,
274 total_tokens_out,
275 };
276
277 let render = RenderConfig {
279 title: manifest.name.clone(),
280 theme: None,
281 sections: RenderConfig::default_sections(),
282 generate_preview: true,
283 };
284
285 let tool_usage = derive_tool_usage(&side_effects, &manifest.authorized_tools);
287
288 SessionReceipt {
289 type_: RECEIPT_TYPE.into(),
290 schema_version: Some(RECEIPT_SCHEMA_VERSION.into()),
291 session,
292 participants,
293 hosts,
294 tools,
295 agent_graph,
296 timeline,
297 side_effects,
298 artifacts: artifact_entries,
299 proofs,
300 merkle: merkle_section,
301 render,
302 tool_usage,
303 }
304 }
305
306 pub fn to_canonical_json(receipt: &SessionReceipt) -> Result<Vec<u8>, serde_json::Error> {
311 serde_json::to_vec(receipt)
312 }
313
314 pub fn digest(receipt: &SessionReceipt) -> Result<String, serde_json::Error> {
316 let bytes = Self::to_canonical_json(receipt)?;
317 let hash = Sha256::digest(&bytes);
318 Ok(format!("sha256:{}", hex::encode(hash)))
319 }
320}
321
322fn compute_participants(graph: &AgentGraph, manifest: &SessionManifest) -> Participants {
325 use std::collections::BTreeSet;
326
327 let mut tool_runtimes: BTreeSet<String> = BTreeSet::new();
328 let total_agents = graph.nodes.len() as u32;
330 let spawned_subagents = graph.spawn_count();
331 let handoffs = graph.handoff_count();
332 let max_depth = graph.max_depth();
333 let host_ids = graph.host_ids();
334
335 for tool in &manifest.tools {
337 if let Some(ref rt) = tool.tool_runtime_id {
338 tool_runtimes.insert(rt.clone());
339 }
340 }
341
342 let root = graph.nodes.iter()
344 .filter(|n| n.depth == 0)
345 .min_by_key(|n| n.started_at.as_deref().unwrap_or(""))
346 .map(|n| n.agent_instance_id.clone());
347
348 let final_output = graph.nodes.iter()
350 .filter(|n| n.completed_at.is_some())
351 .max_by_key(|n| n.completed_at.as_deref().unwrap_or(""))
352 .map(|n| n.agent_instance_id.clone());
353
354 Participants {
355 root_agent_instance_id: root.or(manifest.participants.root_agent_instance_id.clone()),
356 final_output_agent_instance_id: final_output.or(manifest.participants.final_output_agent_instance_id.clone()),
357 total_agents,
358 spawned_subagents,
359 handoffs,
360 max_depth,
361 hosts: host_ids.len() as u32,
362 tool_runtimes: tool_runtimes.len() as u32,
363 }
364}
365
366fn compute_hosts(events: &[SessionEvent], manifest_hosts: &[HostInfo]) -> Vec<HostInfo> {
367 use std::collections::BTreeMap;
368
369 let mut hosts: BTreeMap<String, HostInfo> = BTreeMap::new();
370
371 for h in manifest_hosts {
373 hosts.insert(h.host_id.clone(), h.clone());
374 }
375
376 for e in events {
378 hosts.entry(e.host_id.clone()).or_insert_with(|| HostInfo {
379 host_id: e.host_id.clone(),
380 hostname: None,
381 os: None,
382 arch: None,
383 });
384 }
385
386 hosts.into_values().collect()
387}
388
389fn compute_tools(events: &[SessionEvent], manifest_tools: &[ToolInfo]) -> Vec<ToolInfo> {
390 use std::collections::BTreeMap;
391
392 let mut tools: BTreeMap<String, ToolInfo> = BTreeMap::new();
393
394 for t in manifest_tools {
396 tools.insert(t.tool_id.clone(), t.clone());
397 }
398
399 for e in events {
401 if let super::event::EventType::AgentCalledTool { ref tool_name, .. } = e.event_type {
402 let entry = tools.entry(tool_name.clone()).or_insert_with(|| ToolInfo {
403 tool_id: tool_name.clone(),
404 tool_name: tool_name.clone(),
405 tool_runtime_id: e.tool_runtime_id.clone(),
406 invocation_count: 0,
407 });
408 entry.invocation_count += 1;
409 }
410 }
411
412 tools.into_values().collect()
413}
414
415fn build_merkle(artifacts: &[ArtifactEntry]) -> (MerkleSection, Option<MerkleTree>) {
416 if artifacts.is_empty() {
417 return (MerkleSection::default(), None);
418 }
419
420 let mut tree = MerkleTree::new();
421 for art in artifacts {
422 tree.append(&art.artifact_id);
423 }
424
425 let root = tree.root().map(|r| format!("mroot_{}", hex::encode(r)));
426
427 let inclusion_proofs: Vec<InclusionProofEntry> = artifacts.iter().enumerate()
429 .filter_map(|(i, art)| {
430 tree.inclusion_proof(i).map(|proof| InclusionProofEntry {
431 artifact_id: art.artifact_id.clone(),
432 leaf_index: i,
433 proof,
434 })
435 })
436 .collect();
437
438 let section = MerkleSection {
439 leaf_count: artifacts.len(),
440 root,
441 checkpoint_id: None,
442 inclusion_proofs,
443 };
444
445 (section, Some(tree))
446}
447
448pub fn parse_ship_id_from_actor(actor: &str) -> Option<String> {
451 let rest = actor.strip_prefix("ship://")?;
452 let id = rest.split('/').next().unwrap_or(rest);
454 if id.is_empty() { None } else { Some(id.to_string()) }
455}
456
457fn derive_tool_usage(
460 side_effects: &SideEffects,
461 authorized_tools: &[String],
462) -> Option<ToolUsage> {
463 use std::collections::BTreeMap;
464
465 if side_effects.tool_invocations.is_empty() && authorized_tools.is_empty() {
466 return None;
467 }
468
469 let mut counts: BTreeMap<String, u32> = BTreeMap::new();
471 for inv in &side_effects.tool_invocations {
472 *counts.entry(inv.tool_name.clone()).or_insert(0) += 1;
473 }
474
475 let actual: Vec<ToolUsageEntry> = counts.iter()
476 .map(|(name, &count)| ToolUsageEntry { tool_name: name.clone(), count })
477 .collect();
478
479 let unauthorized = if authorized_tools.is_empty() {
481 Vec::new()
482 } else {
483 let declared_set: std::collections::BTreeSet<&str> = authorized_tools.iter()
484 .map(|s| s.as_str())
485 .collect();
486 counts.keys()
487 .filter(|name| !declared_set.contains(name.as_str()))
488 .cloned()
489 .collect()
490 };
491
492 Some(ToolUsage {
493 declared: authorized_tools.to_vec(),
494 actual,
495 unauthorized,
496 })
497}
498
499fn event_type_label(et: &super::event::EventType) -> String {
500 use super::event::EventType::*;
501 match et {
502 SessionStarted => "session.started",
503 SessionClosed { .. } => "session.closed",
504 AgentStarted { .. } => "agent.started",
505 AgentSpawned { .. } => "agent.spawned",
506 AgentHandoff { .. } => "agent.handoff",
507 AgentCollaborated { .. } => "agent.collaborated",
508 AgentReturned { .. } => "agent.returned",
509 AgentCompleted { .. } => "agent.completed",
510 AgentFailed { .. } => "agent.failed",
511 AgentCalledTool { .. } => "agent.called_tool",
512 AgentReadFile { .. } => "agent.read_file",
513 AgentWroteFile { .. } => "agent.wrote_file",
514 AgentOpenedPort { .. } => "agent.opened_port",
515 AgentConnectedNetwork { .. } => "agent.connected_network",
516 AgentStartedProcess { .. } => "agent.started_process",
517 AgentCompletedProcess { .. } => "agent.completed_process",
518 AgentDecision { .. } => "agent.decision",
519 }.into()
520}
521
522fn event_summary(et: &super::event::EventType) -> Option<String> {
524 use super::event::EventType::*;
525 match et {
526 SessionStarted => Some("Session started".into()),
527 SessionClosed { summary, .. } => summary.clone().or(Some("Session closed".into())),
528 AgentSpawned { reason, .. } => reason.clone(),
529 AgentHandoff { from_agent_instance_id, to_agent_instance_id, .. } => {
530 Some(format!("{from_agent_instance_id} -> {to_agent_instance_id}"))
531 }
532 AgentCalledTool { tool_name, .. } => Some(format!("Called {tool_name}")),
533 AgentReadFile { file_path, .. } => Some(format!("Read {file_path}")),
534 AgentWroteFile { file_path, .. } => Some(format!("Wrote {file_path}")),
535 AgentOpenedPort { port, .. } => Some(format!("Opened port {port}")),
536 AgentConnectedNetwork { destination, .. } => Some(format!("Connected to {destination}")),
537 AgentStartedProcess { process_name, .. } => Some(format!("Started {process_name}")),
538 AgentCompletedProcess { process_name, exit_code, .. } => {
539 Some(format!("Completed {process_name} (exit {})", exit_code.unwrap_or(-1)))
540 }
541 AgentCompleted { termination_reason } => termination_reason.clone().or(Some("Agent completed".into())),
542 AgentFailed { reason } => reason.clone().or(Some("Agent failed".into())),
543 AgentDecision { model, summary, provider, .. } => {
544 let mut parts = Vec::new();
545 if let Some(s) = summary { parts.push(s.clone()); }
546 if let Some(m) = model { parts.push(format!("model: {m}")); }
547 if let Some(p) = provider { parts.push(format!("via {p}")); }
548 if parts.is_empty() { Some("LLM decision".into()) } else { Some(parts.join(" | ")) }
549 }
550 _ => None,
551 }
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557 use crate::session::event::*;
558
559 fn make_manifest() -> SessionManifest {
560 SessionManifest::new(
561 "ssn_001".into(),
562 "agent://test".into(),
563 "2026-04-05T08:00:00Z".into(),
564 1743843600000,
565 )
566 }
567
568 fn make_events() -> Vec<SessionEvent> {
569 let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
570 SessionEvent {
571 session_id: "ssn_001".into(),
572 event_id: format!("evt_{:016x}", seq),
573 timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
574 sequence_no: seq,
575 trace_id: "trace_1".into(),
576 span_id: format!("span_{seq}"),
577 parent_span_id: None,
578 agent_id: format!("agent://{inst}"),
579 agent_instance_id: inst.into(),
580 agent_name: inst.into(),
581 agent_role: None,
582 host_id: "host_1".into(),
583 tool_runtime_id: None,
584 event_type: et,
585 artifact_ref: None,
586 meta: None,
587 }
588 };
589
590 vec![
591 mk(0, "root", EventType::SessionStarted),
592 mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
593 mk(2, "worker", EventType::AgentSpawned { spawned_by_agent_instance_id: "root".into(), reason: Some("review".into()) }),
594 mk(3, "worker", EventType::AgentCalledTool { tool_name: "read_file".into(), tool_input_digest: None, tool_output_digest: None, duration_ms: Some(5) }),
595 mk(4, "worker", EventType::AgentWroteFile { file_path: "src/fix.rs".into(), digest: None, operation: None, additions: None, deletions: None }),
596 mk(5, "worker", EventType::AgentCompleted { termination_reason: None }),
597 mk(6, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(360000) }),
598 ]
599 }
600
601 #[test]
602 fn compose_receipt() {
603 let manifest = make_manifest();
604 let events = make_events();
605 let artifacts = vec![
606 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
607 ArtifactEntry { artifact_id: "art_002".into(), payload_type: "action".into(), digest: None, signed_at: None },
608 ];
609
610 let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
611
612 assert_eq!(receipt.type_, RECEIPT_TYPE);
613 assert_eq!(receipt.session.id, "ssn_001");
614 assert_eq!(receipt.timeline.len(), 7);
615 assert_eq!(receipt.agent_graph.nodes.len(), 2); assert_eq!(receipt.side_effects.files_written.len(), 1);
617 assert_eq!(receipt.merkle.leaf_count, 2);
618 assert!(receipt.merkle.root.is_some());
619 }
620
621 #[test]
622 fn new_receipts_carry_schema_version() {
623 let manifest = make_manifest();
624 let events = make_events();
625 let artifacts = vec![
626 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
627 ];
628 let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
629 assert_eq!(receipt.schema_version.as_deref(), Some(RECEIPT_SCHEMA_VERSION));
630 let json = String::from_utf8(ReceiptComposer::to_canonical_json(&receipt).unwrap()).unwrap();
632 assert!(json.contains(r#""schema_version":"1""#), "missing schema_version: {json}");
633 }
634
635 #[test]
636 fn legacy_receipt_without_schema_version_round_trips_byte_identical() {
637 let manifest = make_manifest();
642 let events = make_events();
643 let artifacts = vec![
644 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
645 ];
646 let mut receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
647 receipt.schema_version = None; let original = ReceiptComposer::to_canonical_json(&receipt).unwrap();
650 let original_str = std::str::from_utf8(&original).unwrap();
652 assert!(!original_str.contains("schema_version"),
653 "schema_version must be skipped when None");
654
655 let parsed: SessionReceipt = serde_json::from_slice(&original).unwrap();
656 assert!(parsed.schema_version.is_none(), "legacy receipts must parse with schema_version=None");
657
658 let reserialized = ReceiptComposer::to_canonical_json(&parsed).unwrap();
659 assert_eq!(original, reserialized,
660 "legacy receipt must round-trip byte-identical so package determinism check passes");
661 }
662
663 #[test]
664 fn canonical_json_is_deterministic() {
665 let manifest = make_manifest();
666 let events = make_events();
667 let artifacts = vec![
668 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
669 ];
670
671 let r1 = ReceiptComposer::compose(&manifest, &events, artifacts.clone());
672 let r2 = ReceiptComposer::compose(&manifest, &events, artifacts);
673
674 let j1 = ReceiptComposer::to_canonical_json(&r1).unwrap();
675 let j2 = ReceiptComposer::to_canonical_json(&r2).unwrap();
676 assert_eq!(j1, j2);
677
678 let d1 = ReceiptComposer::digest(&r1).unwrap();
679 let d2 = ReceiptComposer::digest(&r2).unwrap();
680 assert_eq!(d1, d2);
681 }
682}