1use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10use crate::merkle::{InclusionProof, MerkleTree};
11
12use super::event::SessionEvent;
13use super::graph::AgentGraph;
14use super::manifest::{
15 HostInfo, LifecycleMode, Participants, RoomInfo, 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 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub authority: Option<AuthoritySection>,
65
66 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub custody: Option<Custody>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95pub struct Custody {
96 pub mode: CustodyMode,
101
102 pub signer: String,
105
106 pub on_behalf_of: String,
110
111 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub reason: Option<String>,
115}
116
117#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
119#[serde(rename_all = "snake_case")]
120pub enum CustodyMode {
121 Delegated,
126}
127
128impl Custody {
129 pub fn delegated(signer: impl Into<String>, on_behalf_of: impl Into<String>) -> Self {
131 Self {
132 mode: CustodyMode::Delegated,
133 signer: signer.into(),
134 on_behalf_of: on_behalf_of.into(),
135 reason: None,
136 }
137 }
138
139 pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
141 self.reason = Some(reason.into());
142 self
143 }
144}
145
146#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153pub struct AuthoritySection {
154 pub actions: Vec<AuthorityEntry>,
155 pub checked: u32,
157 pub violations: u32,
160 pub unverified: u32,
163 pub bearer: u32,
166}
167
168#[derive(Debug, Clone, Default, Serialize, Deserialize)]
170pub struct AuthorityEntry {
171 pub artifact_id: String,
172 pub action: String,
174 pub verdict: String,
176 #[serde(default, skip_serializing_if = "Vec::is_empty")]
178 pub reasons: Vec<String>,
179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
181 pub scope: Vec<String>,
182 pub audience: String,
183 pub grant_id: String,
184 pub holder_bound: bool,
187 pub delegation: String,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub delegation_hops: Option<u32>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub effect_finality: Option<String>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub resolution: Option<String>,
200}
201
202#[derive(Debug, Clone, Default, Serialize, Deserialize)]
204pub struct ToolUsage {
205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub declared: Vec<String>,
208 #[serde(default, skip_serializing_if = "Vec::is_empty")]
210 pub actual: Vec<ToolUsageEntry>,
211 #[serde(default, skip_serializing_if = "Vec::is_empty")]
213 pub unauthorized: Vec<String>,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct ToolUsageEntry {
219 pub tool_name: String,
220 pub count: u32,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct SessionSection {
226 pub id: String,
227 #[serde(skip_serializing_if = "Option::is_none")]
228 pub name: Option<String>,
229 pub mode: LifecycleMode,
230 pub started_at: String,
231 #[serde(skip_serializing_if = "Option::is_none")]
232 pub ended_at: Option<String>,
233 pub status: SessionStatus,
234 #[serde(skip_serializing_if = "Option::is_none")]
235 pub duration_ms: Option<u64>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub ship_id: Option<String>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub narrative: Option<Narrative>,
246 #[serde(default)]
248 pub total_tokens_in: u64,
249 #[serde(default)]
251 pub total_tokens_out: u64,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub room: Option<RoomInfo>,
259}
260
261#[derive(Debug, Clone, Default, Serialize, Deserialize)]
263pub struct Narrative {
264 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub headline: Option<String>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub summary: Option<String>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub review: Option<String>,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct TimelineEntry {
278 pub sequence_no: u64,
279 pub timestamp: String,
280 pub event_id: String,
281 pub event_type: String,
282 pub agent_instance_id: String,
283 pub agent_name: String,
284 pub host_id: String,
285 #[serde(skip_serializing_if = "Option::is_none")]
286 pub summary: Option<String>,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct ArtifactEntry {
292 pub artifact_id: String,
293 pub payload_type: String,
294 #[serde(skip_serializing_if = "Option::is_none")]
295 pub digest: Option<String>,
296 #[serde(skip_serializing_if = "Option::is_none")]
297 pub signed_at: Option<String>,
298}
299
300#[derive(Debug, Clone, Default, Serialize, Deserialize)]
302pub struct ProofsSection {
303 #[serde(default)]
304 pub signature_count: u32,
305 #[serde(default)]
306 pub signatures_valid: bool,
307 #[serde(default)]
308 pub merkle_root_valid: bool,
309 #[serde(default)]
310 pub inclusion_proofs_count: u32,
311 #[serde(default)]
312 pub zk_proofs_present: bool,
313 #[serde(default, skip_serializing_if = "is_zero_u32")]
322 pub event_log_skipped: u32,
323 #[serde(default, skip_serializing_if = "is_zero_u32")]
324 pub reconcile_untracked_truncated: u32,
325 #[serde(default, skip_serializing_if = "is_zero_u32")]
326 pub reconcile_untracked_cap: u32,
327 #[serde(default, skip_serializing_if = "is_false")]
335 pub reconcile_degraded: bool,
336}
337
338fn is_zero_u32(n: &u32) -> bool {
339 *n == 0
340}
341fn is_false(b: &bool) -> bool {
342 !*b
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct MerkleSection {
348 pub leaf_count: usize,
349 #[serde(skip_serializing_if = "Option::is_none")]
350 pub root: Option<String>,
351 #[serde(skip_serializing_if = "Option::is_none")]
352 pub checkpoint_id: Option<String>,
353 #[serde(default, skip_serializing_if = "Vec::is_empty")]
354 pub inclusion_proofs: Vec<InclusionProofEntry>,
355 #[serde(default = "crate::merkle::tree::default_merkle_version_v1")]
360 pub merkle_version: u8,
361}
362
363impl Default for MerkleSection {
364 fn default() -> Self {
365 Self {
369 leaf_count: 0,
370 root: None,
371 checkpoint_id: None,
372 inclusion_proofs: Vec::new(),
373 merkle_version: crate::merkle::tree::MERKLE_VERSION_V2,
374 }
375 }
376}
377
378#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct InclusionProofEntry {
381 pub artifact_id: String,
382 pub leaf_index: usize,
383 pub proof: InclusionProof,
384}
385
386pub struct ReceiptComposer;
390
391impl ReceiptComposer {
392 pub fn compose(
394 manifest: &SessionManifest,
395 events: &[SessionEvent],
396 artifact_entries: Vec<ArtifactEntry>,
397 ) -> SessionReceipt {
398 Self::compose_with_custody(manifest, events, artifact_entries, None)
399 }
400
401 pub fn compose_with_custody(
415 manifest: &SessionManifest,
416 events: &[SessionEvent],
417 artifact_entries: Vec<ArtifactEntry>,
418 custody: Option<Custody>,
419 ) -> SessionReceipt {
420 let agent_graph = AgentGraph::from_events(events);
422
423 let side_effects = SideEffects::from_events(events);
425
426 let mut timeline: Vec<TimelineEntry> = events
428 .iter()
429 .map(|e| TimelineEntry {
430 sequence_no: e.sequence_no,
431 timestamp: e.timestamp.clone(),
432 event_id: e.event_id.clone(),
433 event_type: event_type_label(&e.event_type),
434 agent_instance_id: e.agent_instance_id.clone(),
435 agent_name: e.agent_name.clone(),
436 host_id: e.host_id.clone(),
437 summary: event_summary(&e.event_type),
438 })
439 .collect();
440
441 timeline.sort_by(|a, b| {
443 a.timestamp
444 .cmp(&b.timestamp)
445 .then(a.sequence_no.cmp(&b.sequence_no))
446 .then(a.event_id.cmp(&b.event_id))
447 });
448
449 let participants = compute_participants(&agent_graph, manifest);
451
452 let hosts = compute_hosts(events, &manifest.hosts);
454 let tools = compute_tools(events, &manifest.tools);
455
456 let duration_ms = events.iter().find_map(|e| {
458 if let super::event::EventType::SessionClosed { duration_ms, .. } = &e.event_type {
459 *duration_ms
460 } else {
461 None
462 }
463 });
464
465 let (merkle_section, merkle_tree) = build_merkle(&artifact_entries);
467
468 let proofs = ProofsSection {
472 signature_count: artifact_entries.len() as u32,
473 signatures_valid: false,
480 merkle_root_valid: merkle_tree.is_some(),
481 inclusion_proofs_count: merkle_section.inclusion_proofs.len() as u32,
482 zk_proofs_present: false,
483 event_log_skipped: 0, reconcile_untracked_truncated: 0,
485 reconcile_untracked_cap: 0,
486 reconcile_degraded: false, };
488
489 let total_tokens_in: u64 = agent_graph.nodes.iter().map(|n| n.tokens_in).sum();
492 let total_tokens_out: u64 = agent_graph.nodes.iter().map(|n| n.tokens_out).sum();
493
494 let session = SessionSection {
496 id: manifest.session_id.clone(),
497 name: manifest.name.clone(),
498 mode: manifest.mode.clone(),
499 started_at: manifest.started_at.clone(),
500 ended_at: manifest.closed_at.clone(),
501 status: manifest.status.clone(),
502 duration_ms,
503 ship_id: parse_ship_id_from_actor(&manifest.actor),
504 narrative: manifest.summary.as_ref().map(|s| Narrative {
505 headline: manifest.name.clone(),
506 summary: Some(s.clone()),
507 review: None,
508 }),
509 total_tokens_in,
510 total_tokens_out,
511 room: manifest.room.clone(),
512 };
513
514 let render = RenderConfig {
516 title: manifest.name.clone(),
517 theme: None,
518 sections: RenderConfig::default_sections(),
519 generate_preview: true,
520 };
521
522 let tool_usage = derive_tool_usage(&side_effects, &manifest.authorized_tools);
524
525 SessionReceipt {
526 type_: RECEIPT_TYPE.into(),
527 schema_version: Some(RECEIPT_SCHEMA_VERSION.into()),
528 session,
529 participants,
530 hosts,
531 tools,
532 agent_graph,
533 timeline,
534 side_effects,
535 artifacts: artifact_entries,
536 proofs,
537 merkle: merkle_section,
538 render,
539 tool_usage,
540 authority: None,
544 custody,
545 }
546 }
547
548 pub fn to_canonical_json(receipt: &SessionReceipt) -> Result<Vec<u8>, serde_json::Error> {
553 serde_json::to_vec(receipt)
554 }
555
556 pub fn digest(receipt: &SessionReceipt) -> Result<String, serde_json::Error> {
558 let bytes = Self::to_canonical_json(receipt)?;
559 let hash = Sha256::digest(&bytes);
560 Ok(format!("sha256:{}", hex::encode(hash)))
561 }
562}
563
564fn compute_participants(graph: &AgentGraph, manifest: &SessionManifest) -> Participants {
567 use std::collections::BTreeSet;
568
569 let mut tool_runtimes: BTreeSet<String> = BTreeSet::new();
570 let total_agents = graph.nodes.len() as u32;
572 let spawned_subagents = graph.spawn_count();
573 let handoffs = graph.handoff_count();
574 let max_depth = graph.max_depth();
575 let host_ids = graph.host_ids();
576
577 for tool in &manifest.tools {
579 if let Some(ref rt) = tool.tool_runtime_id {
580 tool_runtimes.insert(rt.clone());
581 }
582 }
583
584 let root = graph
586 .nodes
587 .iter()
588 .filter(|n| n.depth == 0)
589 .min_by_key(|n| n.started_at.as_deref().unwrap_or(""))
590 .map(|n| n.agent_instance_id.clone());
591
592 let final_output = graph
594 .nodes
595 .iter()
596 .filter(|n| n.completed_at.is_some())
597 .max_by_key(|n| n.completed_at.as_deref().unwrap_or(""))
598 .map(|n| n.agent_instance_id.clone());
599
600 Participants {
601 root_agent_instance_id: root.or(manifest.participants.root_agent_instance_id.clone()),
602 final_output_agent_instance_id: final_output
603 .or(manifest.participants.final_output_agent_instance_id.clone()),
604 total_agents,
605 spawned_subagents,
606 handoffs,
607 max_depth,
608 hosts: host_ids.len() as u32,
609 tool_runtimes: tool_runtimes.len() as u32,
610 }
611}
612
613fn compute_hosts(events: &[SessionEvent], manifest_hosts: &[HostInfo]) -> Vec<HostInfo> {
614 use std::collections::BTreeMap;
615
616 let mut hosts: BTreeMap<String, HostInfo> = BTreeMap::new();
617
618 for h in manifest_hosts {
620 hosts.insert(h.host_id.clone(), h.clone());
621 }
622
623 for e in events {
625 hosts.entry(e.host_id.clone()).or_insert_with(|| HostInfo {
626 host_id: e.host_id.clone(),
627 hostname: None,
628 os: None,
629 arch: None,
630 });
631 }
632
633 hosts.into_values().collect()
634}
635
636fn compute_tools(events: &[SessionEvent], manifest_tools: &[ToolInfo]) -> Vec<ToolInfo> {
637 use std::collections::BTreeMap;
638
639 let mut tools: BTreeMap<String, ToolInfo> = BTreeMap::new();
640
641 for t in manifest_tools {
643 tools.insert(t.tool_id.clone(), t.clone());
644 }
645
646 for e in events {
648 if let super::event::EventType::AgentCalledTool { ref tool_name, .. } = e.event_type {
649 let entry = tools.entry(tool_name.clone()).or_insert_with(|| ToolInfo {
650 tool_id: tool_name.clone(),
651 tool_name: tool_name.clone(),
652 tool_runtime_id: e.tool_runtime_id.clone(),
653 invocation_count: 0,
654 });
655 entry.invocation_count += 1;
656 }
657 }
658
659 tools.into_values().collect()
660}
661
662fn build_merkle(artifacts: &[ArtifactEntry]) -> (MerkleSection, Option<MerkleTree>) {
663 if artifacts.is_empty() {
664 return (MerkleSection::default(), None);
665 }
666
667 let mut tree = MerkleTree::new();
668 for art in artifacts {
669 tree.append(&art.artifact_id);
670 }
671
672 let root = tree.root().map(|r| format!("mroot_{}", hex::encode(r)));
673
674 let inclusion_proofs: Vec<InclusionProofEntry> = artifacts
676 .iter()
677 .enumerate()
678 .filter_map(|(i, art)| {
679 tree.inclusion_proof(i).map(|proof| InclusionProofEntry {
680 artifact_id: art.artifact_id.clone(),
681 leaf_index: i,
682 proof,
683 })
684 })
685 .collect();
686
687 let section = MerkleSection {
688 leaf_count: artifacts.len(),
689 root,
690 checkpoint_id: None,
691 inclusion_proofs,
692 merkle_version: tree.version(),
693 };
694
695 (section, Some(tree))
696}
697
698pub fn parse_ship_id_from_actor(actor: &str) -> Option<String> {
701 let rest = actor.strip_prefix("ship://")?;
702 let id = rest.split('/').next().unwrap_or(rest);
704 if id.is_empty() {
705 None
706 } else {
707 Some(id.to_string())
708 }
709}
710
711const TOOL_ALIASES: &[(&str, &[&str])] = &[
761 ("read_file", &["read_file", "Read"]),
763 (
764 "write_file",
765 &[
766 "write_file",
767 "Write",
768 "Edit",
769 "MultiEdit",
770 "NotebookEdit",
771 "edit_file",
772 ],
773 ),
774 ("bash", &["bash", "Bash", "shell"]),
775 ("web_fetch", &["web_fetch", "WebFetch", "webfetch"]),
776];
777
778fn source_attributes_a_tool(source: Option<&str>) -> bool {
803 matches!(
804 source,
805 None | Some("hook") | Some("mcp") | Some("shell-wrap") | Some("session-event-cli"),
806 )
807}
808
809fn count_attributed<'a, F>(
812 items: usize,
813 source_at: F,
814 canonical: &str,
815 counts: &mut std::collections::BTreeMap<String, u32>,
816) where
817 F: Fn(usize) -> Option<&'a str>,
818{
819 let n: u32 = (0..items)
820 .filter(|i| source_attributes_a_tool(source_at(*i)))
821 .count() as u32;
822 if n > 0 {
823 *counts.entry(canonical.to_string()).or_insert(0) += n;
824 }
825}
826
827fn derive_tool_usage(side_effects: &SideEffects, authorized_tools: &[String]) -> Option<ToolUsage> {
828 use std::collections::BTreeMap;
829
830 let total_specialized = side_effects.files_read.len()
831 + side_effects.files_written.len()
832 + side_effects.processes.len()
833 + side_effects.network_connections.len();
834
835 if side_effects.tool_invocations.is_empty()
836 && total_specialized == 0
837 && authorized_tools.is_empty()
838 {
839 return None;
840 }
841
842 let mut counts: BTreeMap<String, u32> = BTreeMap::new();
843
844 for inv in &side_effects.tool_invocations {
851 *counts.entry(inv.tool_name.clone()).or_insert(0) += 1;
852 }
853
854 let fr = &side_effects.files_read;
859 count_attributed(
860 fr.len(),
861 |i| fr[i].source.as_deref(),
862 "read_file",
863 &mut counts,
864 );
865 let fw = &side_effects.files_written;
866 count_attributed(
867 fw.len(),
868 |i| fw[i].source.as_deref(),
869 "write_file",
870 &mut counts,
871 );
872 let pr = &side_effects.processes;
873 count_attributed(pr.len(), |i| pr[i].source.as_deref(), "bash", &mut counts);
874 if !side_effects.network_connections.is_empty() {
878 *counts.entry("web_fetch".to_string()).or_insert(0) +=
879 side_effects.network_connections.len() as u32;
880 }
881
882 let actual: Vec<ToolUsageEntry> = counts
883 .iter()
884 .map(|(name, &count)| ToolUsageEntry {
885 tool_name: name.clone(),
886 count,
887 })
888 .collect();
889
890 let unauthorized = if authorized_tools.is_empty() {
896 Vec::new()
897 } else {
898 let declared_set: std::collections::BTreeSet<&str> =
899 authorized_tools.iter().map(|s| s.as_str()).collect();
900 counts
901 .keys()
902 .filter(|actual_name| !is_authorized(actual_name, &declared_set))
903 .cloned()
904 .collect()
905 };
906
907 Some(ToolUsage {
908 declared: authorized_tools.to_vec(),
909 actual,
910 unauthorized,
911 })
912}
913
914fn is_authorized(actual_name: &str, declared_set: &std::collections::BTreeSet<&str>) -> bool {
919 if declared_set.contains(actual_name) {
921 return true;
922 }
923 for (canonical, aliases) in TOOL_ALIASES {
926 if *canonical == actual_name || aliases.contains(&actual_name) {
927 for alias in *aliases {
928 if declared_set.contains(*alias) {
929 return true;
930 }
931 }
932 return false;
933 }
934 }
935 false
936}
937
938fn event_type_label(et: &super::event::EventType) -> String {
939 use super::event::EventType::*;
940 match et {
941 SessionStarted => "session.started",
942 SessionClosed { .. } => "session.closed",
943 AgentStarted { .. } => "agent.started",
944 AgentSpawned { .. } => "agent.spawned",
945 AgentHandoff { .. } => "agent.handoff",
946 AgentCollaborated { .. } => "agent.collaborated",
947 AgentReturned { .. } => "agent.returned",
948 AgentCompleted { .. } => "agent.completed",
949 AgentFailed { .. } => "agent.failed",
950 AgentCalledTool { .. } => "agent.called_tool",
951 AgentReadFile { .. } => "agent.read_file",
952 AgentWroteFile { .. } => "agent.wrote_file",
953 AgentOpenedPort { .. } => "agent.opened_port",
954 AgentConnectedNetwork { .. } => "agent.connected_network",
955 AgentStartedProcess { .. } => "agent.started_process",
956 AgentCompletedProcess { .. } => "agent.completed_process",
957 AgentDecision { .. } => "agent.decision",
958 }
959 .into()
960}
961
962fn event_summary(et: &super::event::EventType) -> Option<String> {
964 use super::event::EventType::*;
965 match et {
966 SessionStarted => Some("Session started".into()),
967 SessionClosed { summary, .. } => summary.clone().or(Some("Session closed".into())),
968 AgentSpawned { reason, .. } => reason.clone(),
969 AgentHandoff {
970 from_agent_instance_id,
971 to_agent_instance_id,
972 ..
973 } => Some(format!(
974 "{from_agent_instance_id} -> {to_agent_instance_id}"
975 )),
976 AgentCalledTool { tool_name, .. } => Some(format!("Called {tool_name}")),
977 AgentReadFile { file_path, .. } => Some(format!("Read {file_path}")),
978 AgentWroteFile { file_path, .. } => Some(format!("Wrote {file_path}")),
979 AgentOpenedPort { port, .. } => Some(format!("Opened port {port}")),
980 AgentConnectedNetwork { destination, .. } => Some(format!("Connected to {destination}")),
981 AgentStartedProcess { process_name, .. } => Some(format!("Started {process_name}")),
982 AgentCompletedProcess {
983 process_name,
984 exit_code,
985 ..
986 } => Some(format!(
987 "Completed {process_name} (exit {})",
988 exit_code.unwrap_or(-1)
989 )),
990 AgentCompleted { termination_reason } => termination_reason
991 .clone()
992 .or(Some("Agent completed".into())),
993 AgentFailed { reason } => reason.clone().or(Some("Agent failed".into())),
994 AgentDecision {
995 model,
996 summary,
997 provider,
998 ..
999 } => {
1000 let mut parts = Vec::new();
1001 if let Some(s) = summary {
1002 parts.push(s.clone());
1003 }
1004 if let Some(m) = model {
1005 parts.push(format!("model: {m}"));
1006 }
1007 if let Some(p) = provider {
1008 parts.push(format!("via {p}"));
1009 }
1010 if parts.is_empty() {
1011 Some("LLM decision".into())
1012 } else {
1013 Some(parts.join(" | "))
1014 }
1015 }
1016 _ => None,
1017 }
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022 use super::*;
1023 use crate::session::event::*;
1024
1025 fn make_manifest() -> SessionManifest {
1026 SessionManifest::new(
1027 "ssn_001".into(),
1028 "agent://test".into(),
1029 "2026-04-05T08:00:00Z".into(),
1030 1743843600000,
1031 )
1032 }
1033
1034 fn mk(seq: u64, inst: &str, et: EventType) -> SessionEvent {
1037 SessionEvent {
1038 session_id: "ssn_001".into(),
1039 event_id: format!("evt_{:016x}", seq),
1040 timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
1041 sequence_no: seq,
1042 trace_id: "trace_1".into(),
1043 span_id: format!("span_{seq}"),
1044 parent_span_id: None,
1045 agent_id: format!("agent://{inst}"),
1046 agent_instance_id: inst.into(),
1047 agent_name: inst.into(),
1048 agent_role: None,
1049 host_id: "host_1".into(),
1050 tool_runtime_id: None,
1051 event_type: et,
1052 artifact_ref: None,
1053 meta: None,
1054 }
1055 }
1056
1057 fn make_events() -> Vec<SessionEvent> {
1058 vec![
1059 mk(0, "root", EventType::SessionStarted),
1060 mk(
1061 1,
1062 "root",
1063 EventType::AgentStarted {
1064 parent_agent_instance_id: None,
1065 },
1066 ),
1067 mk(
1068 2,
1069 "worker",
1070 EventType::AgentSpawned {
1071 spawned_by_agent_instance_id: "root".into(),
1072 reason: Some("review".into()),
1073 },
1074 ),
1075 mk(
1076 3,
1077 "worker",
1078 EventType::AgentCalledTool {
1079 tool_name: "read_file".into(),
1080 tool_input_digest: None,
1081 tool_output_digest: None,
1082 duration_ms: Some(5),
1083 },
1084 ),
1085 mk(
1086 4,
1087 "worker",
1088 EventType::AgentWroteFile {
1089 file_path: "src/fix.rs".into(),
1090 digest: None,
1091 operation: None,
1092 additions: None,
1093 deletions: None,
1094 },
1095 ),
1096 mk(
1097 5,
1098 "worker",
1099 EventType::AgentCompleted {
1100 termination_reason: None,
1101 },
1102 ),
1103 mk(
1104 6,
1105 "root",
1106 EventType::SessionClosed {
1107 summary: Some("Done".into()),
1108 duration_ms: Some(360000),
1109 },
1110 ),
1111 ]
1112 }
1113
1114 #[test]
1115 fn compose_receipt() {
1116 let manifest = make_manifest();
1117 let events = make_events();
1118 let artifacts = vec![
1119 ArtifactEntry {
1120 artifact_id: "art_001".into(),
1121 payload_type: "action".into(),
1122 digest: None,
1123 signed_at: None,
1124 },
1125 ArtifactEntry {
1126 artifact_id: "art_002".into(),
1127 payload_type: "action".into(),
1128 digest: None,
1129 signed_at: None,
1130 },
1131 ];
1132
1133 let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
1134
1135 assert_eq!(receipt.type_, RECEIPT_TYPE);
1136 assert_eq!(receipt.session.id, "ssn_001");
1137 assert_eq!(receipt.timeline.len(), 7);
1138 assert_eq!(receipt.agent_graph.nodes.len(), 2); assert_eq!(receipt.side_effects.files_written.len(), 1);
1140 assert_eq!(receipt.merkle.leaf_count, 2);
1141 assert!(receipt.merkle.root.is_some());
1142 }
1143
1144 #[test]
1145 fn new_receipts_carry_schema_version() {
1146 let manifest = make_manifest();
1147 let events = make_events();
1148 let artifacts = vec![ArtifactEntry {
1149 artifact_id: "art_001".into(),
1150 payload_type: "action".into(),
1151 digest: None,
1152 signed_at: None,
1153 }];
1154 let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
1155 assert_eq!(
1156 receipt.schema_version.as_deref(),
1157 Some(RECEIPT_SCHEMA_VERSION)
1158 );
1159 let json =
1161 String::from_utf8(ReceiptComposer::to_canonical_json(&receipt).unwrap()).unwrap();
1162 assert!(
1163 json.contains(r#""schema_version":"1""#),
1164 "missing schema_version: {json}"
1165 );
1166 }
1167
1168 #[test]
1169 fn legacy_receipt_without_schema_version_round_trips_byte_identical() {
1170 let manifest = make_manifest();
1175 let events = make_events();
1176 let artifacts = vec![ArtifactEntry {
1177 artifact_id: "art_001".into(),
1178 payload_type: "action".into(),
1179 digest: None,
1180 signed_at: None,
1181 }];
1182 let mut receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
1183 receipt.schema_version = None; let original = ReceiptComposer::to_canonical_json(&receipt).unwrap();
1186 let original_str = std::str::from_utf8(&original).unwrap();
1188 assert!(
1189 !original_str.contains("schema_version"),
1190 "schema_version must be skipped when None"
1191 );
1192
1193 let parsed: SessionReceipt = serde_json::from_slice(&original).unwrap();
1194 assert!(
1195 parsed.schema_version.is_none(),
1196 "legacy receipts must parse with schema_version=None"
1197 );
1198
1199 let reserialized = ReceiptComposer::to_canonical_json(&parsed).unwrap();
1200 assert_eq!(
1201 original, reserialized,
1202 "legacy receipt must round-trip byte-identical so package determinism check passes"
1203 );
1204 }
1205
1206 #[test]
1207 fn canonical_json_is_deterministic() {
1208 let manifest = make_manifest();
1209 let events = make_events();
1210 let artifacts = vec![ArtifactEntry {
1211 artifact_id: "art_001".into(),
1212 payload_type: "action".into(),
1213 digest: None,
1214 signed_at: None,
1215 }];
1216
1217 let r1 = ReceiptComposer::compose(&manifest, &events, artifacts.clone());
1218 let r2 = ReceiptComposer::compose(&manifest, &events, artifacts);
1219
1220 let j1 = ReceiptComposer::to_canonical_json(&r1).unwrap();
1221 let j2 = ReceiptComposer::to_canonical_json(&r2).unwrap();
1222 assert_eq!(j1, j2);
1223
1224 let d1 = ReceiptComposer::digest(&r1).unwrap();
1225 let d2 = ReceiptComposer::digest(&r2).unwrap();
1226 assert_eq!(d1, d2);
1227 }
1228
1229 fn manifest_with_authorized(tools: Vec<&str>) -> SessionManifest {
1239 let mut m = make_manifest();
1240 m.authorized_tools = tools.into_iter().map(String::from).collect();
1241 m
1242 }
1243
1244 #[test]
1245 fn cert_omitting_bash_flags_unauthorized_when_session_runs_bash() {
1246 let manifest = manifest_with_authorized(vec!["read_file", "write_file"]); let events = vec![
1251 mk(0, "root", EventType::SessionStarted),
1252 mk(
1253 1,
1254 "agent",
1255 EventType::AgentCompletedProcess {
1256 process_name: "rm -rf /".into(),
1257 exit_code: Some(0),
1258 duration_ms: Some(50),
1259 command: Some("rm -rf /".into()),
1260 },
1261 ),
1262 mk(
1263 2,
1264 "root",
1265 EventType::SessionClosed {
1266 summary: None,
1267 duration_ms: Some(1000),
1268 },
1269 ),
1270 ];
1271 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1272 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1273 assert!(
1274 tu.unauthorized.iter().any(|t| t == "bash"),
1275 "bash must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
1276 tu.unauthorized, tu.actual,
1277 );
1278 }
1279
1280 #[test]
1281 fn cert_omitting_write_flags_unauthorized_when_session_writes_file() {
1282 let manifest = manifest_with_authorized(vec!["read_file", "bash"]); let events = vec![
1284 mk(0, "root", EventType::SessionStarted),
1285 mk(
1286 1,
1287 "agent",
1288 EventType::AgentWroteFile {
1289 file_path: "src/secret.rs".into(),
1290 digest: None,
1291 operation: Some("modified".into()),
1292 additions: Some(10),
1293 deletions: Some(0),
1294 },
1295 ),
1296 mk(
1297 2,
1298 "root",
1299 EventType::SessionClosed {
1300 summary: None,
1301 duration_ms: Some(1000),
1302 },
1303 ),
1304 ];
1305 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1306 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1307 assert!(
1308 tu.unauthorized.iter().any(|t| t == "write_file"),
1309 "write_file must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
1310 tu.unauthorized, tu.actual,
1311 );
1312 }
1313
1314 #[test]
1315 fn cert_includes_read_write_bash_passes_clean_when_all_used() {
1316 let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]);
1317 let events = vec![
1318 mk(0, "root", EventType::SessionStarted),
1319 mk(
1320 1,
1321 "agent",
1322 EventType::AgentReadFile {
1323 file_path: "package.json".into(),
1324 digest: None,
1325 },
1326 ),
1327 mk(
1328 2,
1329 "agent",
1330 EventType::AgentWroteFile {
1331 file_path: "src/lib.rs".into(),
1332 digest: None,
1333 operation: Some("modified".into()),
1334 additions: Some(5),
1335 deletions: Some(2),
1336 },
1337 ),
1338 mk(
1339 3,
1340 "agent",
1341 EventType::AgentCompletedProcess {
1342 process_name: "bun test".into(),
1343 exit_code: Some(0),
1344 duration_ms: Some(2000),
1345 command: Some("bun test".into()),
1346 },
1347 ),
1348 mk(
1349 4,
1350 "root",
1351 EventType::SessionClosed {
1352 summary: None,
1353 duration_ms: Some(5000),
1354 },
1355 ),
1356 ];
1357 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1358 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1359 assert!(
1360 tu.unauthorized.is_empty(),
1361 "all tools declared in cert should pass clean; got unauthorized={:?}",
1362 tu.unauthorized,
1363 );
1364 let actual_names: std::collections::BTreeSet<String> =
1368 tu.actual.iter().map(|e| e.tool_name.clone()).collect();
1369 assert!(actual_names.contains("read_file"));
1370 assert!(actual_names.contains("write_file"));
1371 assert!(actual_names.contains("bash"));
1372 }
1373
1374 #[test]
1375 fn webfetch_unauthorized_flagged_when_cert_omits_it() {
1376 let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]); let events = vec![
1378 mk(0, "root", EventType::SessionStarted),
1379 mk(
1380 1,
1381 "agent",
1382 EventType::AgentConnectedNetwork {
1383 destination: "evil.example.com".into(),
1384 port: Some(443),
1385 },
1386 ),
1387 mk(
1388 2,
1389 "root",
1390 EventType::SessionClosed {
1391 summary: None,
1392 duration_ms: Some(1000),
1393 },
1394 ),
1395 ];
1396 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1397 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1398 assert!(
1399 tu.unauthorized.iter().any(|t| t == "web_fetch"),
1400 "web_fetch must be flagged as unauthorized when cert omits it; got unauthorized={:?}",
1401 tu.unauthorized,
1402 );
1403 }
1404
1405 fn evt_with_source(event_type: EventType, source: &str) -> SessionEvent {
1408 let mut e = mk(99, "agent", event_type);
1409 e.meta = Some(serde_json::json!({"source": source}));
1410 e
1411 }
1412
1413 #[test]
1414 fn titlecase_cert_authorizes_canonical_snake_actuals_via_alias() {
1415 let manifest = manifest_with_authorized(vec!["Read", "Write", "Bash"]);
1418 let events = vec![
1419 mk(0, "root", EventType::SessionStarted),
1420 mk(
1421 1,
1422 "agent",
1423 EventType::AgentReadFile {
1424 file_path: "x".into(),
1425 digest: None,
1426 },
1427 ),
1428 mk(
1429 2,
1430 "agent",
1431 EventType::AgentWroteFile {
1432 file_path: "y".into(),
1433 digest: None,
1434 operation: None,
1435 additions: None,
1436 deletions: None,
1437 },
1438 ),
1439 mk(
1440 3,
1441 "agent",
1442 EventType::AgentCompletedProcess {
1443 process_name: "z".into(),
1444 exit_code: Some(0),
1445 duration_ms: Some(1),
1446 command: None,
1447 },
1448 ),
1449 mk(
1450 4,
1451 "root",
1452 EventType::SessionClosed {
1453 summary: None,
1454 duration_ms: Some(1000),
1455 },
1456 ),
1457 ];
1458 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1459 .tool_usage
1460 .unwrap();
1461 assert!(
1462 tu.unauthorized.is_empty(),
1463 "TitleCase declarations must authorize canonical snake_case actuals via aliases; \
1464 got unauthorized={:?}",
1465 tu.unauthorized,
1466 );
1467 }
1468
1469 #[test]
1470 fn edit_alias_authorizes_specialized_wrote_file() {
1471 let manifest = manifest_with_authorized(vec!["Edit"]);
1476 let events = vec![
1477 mk(0, "root", EventType::SessionStarted),
1478 mk(
1479 1,
1480 "agent",
1481 EventType::AgentWroteFile {
1482 file_path: "x".into(),
1483 digest: None,
1484 operation: None,
1485 additions: None,
1486 deletions: None,
1487 },
1488 ),
1489 mk(
1490 2,
1491 "root",
1492 EventType::SessionClosed {
1493 summary: None,
1494 duration_ms: Some(1000),
1495 },
1496 ),
1497 ];
1498 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1499 .tool_usage
1500 .unwrap();
1501 assert!(
1502 tu.unauthorized.is_empty(),
1503 "Edit alias must authorize write_file"
1504 );
1505 }
1506
1507 #[test]
1508 fn git_reconcile_writes_dont_count_toward_tool_usage() {
1509 let manifest = manifest_with_authorized(vec!["read_file"]);
1513 let events = vec![
1514 mk(0, "root", EventType::SessionStarted),
1515 evt_with_source(
1516 EventType::AgentWroteFile {
1517 file_path: "CHANGELOG.md".into(),
1518 digest: None,
1519 operation: Some("modified".into()),
1520 additions: Some(7),
1521 deletions: Some(2),
1522 },
1523 "git-reconcile",
1524 ),
1525 mk(
1526 2,
1527 "root",
1528 EventType::SessionClosed {
1529 summary: None,
1530 duration_ms: Some(1000),
1531 },
1532 ),
1533 ];
1534 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1535 .tool_usage
1536 .unwrap();
1537 assert!(
1538 !tu.unauthorized.iter().any(|t| t == "write_file"),
1539 "git-reconcile entries must NOT count toward tool_usage; \
1540 got unauthorized={:?}, actual={:?}",
1541 tu.unauthorized,
1542 tu.actual,
1543 );
1544 let actual_names: std::collections::BTreeSet<String> =
1545 tu.actual.iter().map(|e| e.tool_name.clone()).collect();
1546 assert!(
1547 !actual_names.contains("write_file"),
1548 "actual must not include backstop-only writes"
1549 );
1550 }
1551
1552 #[test]
1561 fn hook_emitted_writes_still_count_toward_tool_usage() {
1562 let manifest = manifest_with_authorized(vec!["read_file"]); let events = vec![
1565 mk(0, "root", EventType::SessionStarted),
1566 evt_with_source(
1567 EventType::AgentWroteFile {
1568 file_path: "src/x.rs".into(),
1569 digest: None,
1570 operation: None,
1571 additions: None,
1572 deletions: None,
1573 },
1574 "hook",
1575 ),
1576 mk(
1577 2,
1578 "root",
1579 EventType::SessionClosed {
1580 summary: None,
1581 duration_ms: Some(1000),
1582 },
1583 ),
1584 ];
1585 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1586 .tool_usage
1587 .unwrap();
1588 assert!(
1589 tu.unauthorized.iter().any(|t| t == "write_file"),
1590 "hook-emitted writes MUST count toward tool_usage; got unauthorized={:?}",
1591 tu.unauthorized,
1592 );
1593 }
1594
1595 #[test]
1596 fn legacy_untagged_writes_count_for_back_compat() {
1597 let manifest = manifest_with_authorized(vec!["read_file"]); let events = vec![
1601 mk(0, "root", EventType::SessionStarted),
1602 mk(
1603 1,
1604 "agent",
1605 EventType::AgentWroteFile {
1606 file_path: "x".into(),
1607 digest: None,
1608 operation: None,
1609 additions: None,
1610 deletions: None,
1611 },
1612 ),
1613 mk(
1614 2,
1615 "root",
1616 EventType::SessionClosed {
1617 summary: None,
1618 duration_ms: Some(1000),
1619 },
1620 ),
1621 ];
1622 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1623 .tool_usage
1624 .unwrap();
1625 assert!(
1626 tu.unauthorized.iter().any(|t| t == "write_file"),
1627 "legacy untagged writes must count for back-compat",
1628 );
1629 }
1630}
1631
1632#[cfg(test)]
1633mod custody_tests {
1634 use super::*;
1635
1636 #[test]
1639 fn self_custody_serializes_to_nothing() {
1640 let c: Option<Custody> = None;
1641 let json = serde_json::to_string(&serde_json::json!({ "custody": c })).unwrap();
1642 assert_eq!(json, r#"{"custody":null}"#);
1643 #[derive(Serialize)]
1645 struct Holder {
1646 #[serde(default, skip_serializing_if = "Option::is_none")]
1647 custody: Option<Custody>,
1648 }
1649 let s = serde_json::to_string(&Holder { custody: None }).unwrap();
1650 assert_eq!(s, "{}", "self-custody must add no bytes");
1651 }
1652
1653 #[test]
1657 fn delegated_custody_names_signer_and_subject() {
1658 let c = Custody::delegated("svc://gateway-rooms", "agent://fizz")
1659 .with_reason("browser-mediated room; participants hold no local key");
1660 let v = serde_json::to_value(&c).unwrap();
1661 assert_eq!(v["mode"], "delegated");
1662 assert_eq!(v["signer"], "svc://gateway-rooms");
1663 assert_eq!(v["on_behalf_of"], "agent://fizz");
1664 assert!(v["reason"].as_str().unwrap().contains("no local key"));
1665 }
1666
1667 #[test]
1668 fn reason_is_optional_and_omitted_when_unset() {
1669 let c = Custody::delegated("svc://x", "agent://y");
1670 let v = serde_json::to_value(&c).unwrap();
1671 assert!(v.get("reason").is_none(), "unset reason must not serialize");
1672 }
1673
1674 #[test]
1677 fn custody_round_trips() {
1678 let c = Custody::delegated("svc://gateway-rooms", "agent://fizz").with_reason("r");
1679 let back: Custody = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
1680 assert_eq!(c, back);
1681 }
1682
1683 #[test]
1687 fn custody_is_orthogonal_to_evidence_capture() {
1688 let receipt = serde_json::json!({
1689 "attestation_class": "runtime",
1690 "custody": Custody::delegated("svc://gateway-rooms", "agent://fizz"),
1691 });
1692 assert_eq!(receipt["attestation_class"], "runtime");
1693 assert_eq!(receipt["custody"]["mode"], "delegated");
1694 }
1695}
1696
1697#[cfg(test)]
1698mod custody_wiring_tests {
1699 use super::*;
1700
1701 #[test]
1707 fn a_delegated_receipt_passes_predicate_validation() {
1708 let payload = serde_json::json!({
1709 "session_id": "ssn_room_demo",
1710 "actor": "agent://fizz",
1711 "outcome": "completed",
1712 "started_at": "2026-08-10T10:00:00Z",
1713 "closed_at": "2026-08-10T10:30:00Z",
1714 "attestation_class": "runtime",
1715 "receipt_digest": format!("sha256:{}", "a".repeat(64)),
1716 "custody": {
1717 "mode": "delegated",
1718 "signer": "svc://gateway-rooms",
1719 "on_behalf_of": "agent://fizz",
1720 "reason": "browser-mediated room; participants hold no local key"
1721 }
1722 });
1723 crate::predicates::validate("session.v1", Some(&payload))
1724 .expect("a delegated-custody receipt must validate");
1725 }
1726
1727 #[test]
1729 fn a_self_custody_receipt_still_validates() {
1730 let payload = serde_json::json!({
1731 "session_id": "ssn_plain",
1732 "actor": "ship://local",
1733 "outcome": "completed",
1734 "started_at": "2026-08-10T10:00:00Z",
1735 "closed_at": "2026-08-10T10:30:00Z",
1736 "attestation_class": "self",
1737 "receipt_digest": format!("sha256:{}", "b".repeat(64)),
1738 });
1739 crate::predicates::validate("session.v1", Some(&payload))
1740 .expect("a self-custody receipt must validate");
1741 }
1742
1743 #[test]
1758 fn custody_requires_a_signer_by_type_not_by_validator() {
1759 let c = Custody::delegated("svc://gateway-rooms", "agent://fizz");
1761 assert!(!c.signer.is_empty());
1762 assert!(!c.on_behalf_of.is_empty());
1763
1764 let payload = serde_json::json!({
1767 "session_id": "ssn_bad",
1768 "actor": "agent://fizz",
1769 "outcome": "completed",
1770 "started_at": "2026-08-10T10:00:00Z",
1771 "closed_at": "2026-08-10T10:30:00Z",
1772 "attestation_class": "self",
1773 "receipt_digest": format!("sha256:{}", "c".repeat(64)),
1774 "custody": { "mode": "delegated", "on_behalf_of": "agent://fizz" }
1775 });
1776 assert!(
1777 crate::predicates::validate("session.v1", Some(&payload)).is_ok(),
1778 "core validates top-level fields only; if this starts failing the \
1779 validator gained nested checking and the doc comment above is stale"
1780 );
1781 }
1782
1783 #[test]
1787 fn none_custody_composes_identically() {
1788 let m = SessionManifest::new(
1789 "ssn_x".into(),
1790 "ship://local".into(),
1791 "2026-08-10T10:00:00Z".into(),
1792 1_760_000_000_000,
1793 );
1794 let a = ReceiptComposer::compose(&m, &[], Vec::new());
1795 let b = ReceiptComposer::compose_with_custody(&m, &[], Vec::new(), None);
1796 assert_eq!(
1797 serde_json::to_string(&a).unwrap(),
1798 serde_json::to_string(&b).unwrap()
1799 );
1800 }
1801
1802 #[test]
1803 fn delegated_custody_reaches_the_composed_receipt() {
1804 let m = SessionManifest::new(
1805 "ssn_y".into(),
1806 "agent://fizz".into(),
1807 "2026-08-10T10:00:00Z".into(),
1808 1_760_000_000_000,
1809 );
1810 let r = ReceiptComposer::compose_with_custody(
1811 &m,
1812 &[],
1813 Vec::new(),
1814 Some(Custody::delegated("svc://gateway-rooms", "agent://fizz")),
1815 );
1816 let v = serde_json::to_value(&r).unwrap();
1817 assert_eq!(v["custody"]["signer"], "svc://gateway-rooms");
1818 assert_eq!(v["custody"]["on_behalf_of"], "agent://fizz");
1819 }
1820}