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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
218 pub network_declared: Vec<String>,
219 #[serde(default, skip_serializing_if = "Vec::is_empty")]
222 pub network_off_scope: Vec<String>,
223}
224
225pub fn host_in_scope(host: &str, scope: &[String]) -> bool {
231 let h = host.trim().trim_end_matches('.').to_ascii_lowercase();
232 if h.is_empty() {
233 return false;
234 }
235 scope.iter().any(|pat| {
236 let p = pat.trim().to_ascii_lowercase();
237 if p == "*" {
238 true
239 } else if let Some(suffix) = p.strip_prefix("*.") {
240 h == suffix || h.ends_with(&format!(".{suffix}"))
241 } else {
242 h == p
243 }
244 })
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct ToolUsageEntry {
250 pub tool_name: String,
251 pub count: u32,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct SessionSection {
257 pub id: String,
258 #[serde(skip_serializing_if = "Option::is_none")]
259 pub name: Option<String>,
260 pub mode: LifecycleMode,
261 pub started_at: String,
262 #[serde(skip_serializing_if = "Option::is_none")]
263 pub ended_at: Option<String>,
264 pub status: SessionStatus,
265 #[serde(skip_serializing_if = "Option::is_none")]
266 pub duration_ms: Option<u64>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub ship_id: Option<String>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub workflow_ref: Option<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub narrative: Option<Narrative>,
283 #[serde(default)]
285 pub total_tokens_in: u64,
286 #[serde(default)]
288 pub total_tokens_out: u64,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub room: Option<RoomInfo>,
296}
297
298#[derive(Debug, Clone, Default, Serialize, Deserialize)]
300pub struct Narrative {
301 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub headline: Option<String>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub summary: Option<String>,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub review: Option<String>,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct TimelineEntry {
315 pub sequence_no: u64,
316 pub timestamp: String,
317 pub event_id: String,
318 pub event_type: String,
319 pub agent_instance_id: String,
320 pub agent_name: String,
321 pub host_id: String,
322 #[serde(skip_serializing_if = "Option::is_none")]
323 pub summary: Option<String>,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct ArtifactEntry {
329 pub artifact_id: String,
330 pub payload_type: String,
331 #[serde(skip_serializing_if = "Option::is_none")]
332 pub digest: Option<String>,
333 #[serde(skip_serializing_if = "Option::is_none")]
334 pub signed_at: Option<String>,
335 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
340 pub unchained: bool,
341}
342
343#[derive(Debug, Clone, Default, Serialize, Deserialize)]
345pub struct ProofsSection {
346 #[serde(default)]
347 pub signature_count: u32,
348 #[serde(default)]
349 pub signatures_valid: bool,
350 #[serde(default)]
351 pub merkle_root_valid: bool,
352 #[serde(default)]
353 pub inclusion_proofs_count: u32,
354 #[serde(default)]
355 pub zk_proofs_present: bool,
356 #[serde(default, skip_serializing_if = "is_zero_u32")]
365 pub event_log_skipped: u32,
366 #[serde(default, skip_serializing_if = "is_zero_u32")]
367 pub reconcile_untracked_truncated: u32,
368 #[serde(default, skip_serializing_if = "is_zero_u32")]
369 pub reconcile_untracked_cap: u32,
370 #[serde(default, skip_serializing_if = "is_false")]
378 pub reconcile_degraded: bool,
379}
380
381fn is_zero_u32(n: &u32) -> bool {
382 *n == 0
383}
384fn is_false(b: &bool) -> bool {
385 !*b
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct MerkleSection {
391 pub leaf_count: usize,
392 #[serde(skip_serializing_if = "Option::is_none")]
393 pub root: Option<String>,
394 #[serde(skip_serializing_if = "Option::is_none")]
395 pub checkpoint_id: Option<String>,
396 #[serde(default, skip_serializing_if = "Vec::is_empty")]
397 pub inclusion_proofs: Vec<InclusionProofEntry>,
398 #[serde(default = "crate::merkle::tree::default_merkle_version_v1")]
403 pub merkle_version: u8,
404}
405
406impl Default for MerkleSection {
407 fn default() -> Self {
408 Self {
412 leaf_count: 0,
413 root: None,
414 checkpoint_id: None,
415 inclusion_proofs: Vec::new(),
416 merkle_version: crate::merkle::tree::MERKLE_VERSION_V2,
417 }
418 }
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct InclusionProofEntry {
424 pub artifact_id: String,
425 pub leaf_index: usize,
426 pub proof: InclusionProof,
427}
428
429pub struct ReceiptComposer;
433
434impl ReceiptComposer {
435 pub fn compose(
437 manifest: &SessionManifest,
438 events: &[SessionEvent],
439 artifact_entries: Vec<ArtifactEntry>,
440 ) -> SessionReceipt {
441 Self::compose_with_custody(manifest, events, artifact_entries, None)
442 }
443
444 pub fn compose_with_custody(
458 manifest: &SessionManifest,
459 events: &[SessionEvent],
460 artifact_entries: Vec<ArtifactEntry>,
461 custody: Option<Custody>,
462 ) -> SessionReceipt {
463 let agent_graph = AgentGraph::from_events(events);
465
466 let side_effects = SideEffects::from_events(events);
468
469 let mut timeline: Vec<TimelineEntry> = events
471 .iter()
472 .map(|e| TimelineEntry {
473 sequence_no: e.sequence_no,
474 timestamp: e.timestamp.clone(),
475 event_id: e.event_id.clone(),
476 event_type: event_type_label(&e.event_type),
477 agent_instance_id: e.agent_instance_id.clone(),
478 agent_name: e.agent_name.clone(),
479 host_id: e.host_id.clone(),
480 summary: event_summary(&e.event_type),
481 })
482 .collect();
483
484 timeline.sort_by(|a, b| {
486 a.timestamp
487 .cmp(&b.timestamp)
488 .then(a.sequence_no.cmp(&b.sequence_no))
489 .then(a.event_id.cmp(&b.event_id))
490 });
491
492 let participants = compute_participants(&agent_graph, manifest);
494
495 let hosts = compute_hosts(events, &manifest.hosts);
497 let tools = compute_tools(events, &manifest.tools);
498
499 let duration_ms = events.iter().find_map(|e| {
501 if let super::event::EventType::SessionClosed { duration_ms, .. } = &e.event_type {
502 *duration_ms
503 } else {
504 None
505 }
506 });
507
508 let (merkle_section, merkle_tree) = build_merkle(&artifact_entries);
510
511 let proofs = ProofsSection {
515 signature_count: artifact_entries.len() as u32,
516 signatures_valid: false,
523 merkle_root_valid: merkle_tree.is_some(),
524 inclusion_proofs_count: merkle_section.inclusion_proofs.len() as u32,
525 zk_proofs_present: false,
526 event_log_skipped: 0, reconcile_untracked_truncated: 0,
528 reconcile_untracked_cap: 0,
529 reconcile_degraded: false, };
531
532 let total_tokens_in: u64 = agent_graph.nodes.iter().map(|n| n.tokens_in).sum();
535 let total_tokens_out: u64 = agent_graph.nodes.iter().map(|n| n.tokens_out).sum();
536
537 let session = SessionSection {
539 id: manifest.session_id.clone(),
540 name: manifest.name.clone(),
541 mode: manifest.mode.clone(),
542 started_at: manifest.started_at.clone(),
543 ended_at: manifest.closed_at.clone(),
544 status: manifest.status.clone(),
545 duration_ms,
546 ship_id: parse_ship_id_from_actor(&manifest.actor),
547 workflow_ref: manifest.workflow_ref.clone(),
548 narrative: manifest.summary.as_ref().map(|s| Narrative {
549 headline: manifest.name.clone(),
550 summary: Some(s.clone()),
551 review: None,
552 }),
553 total_tokens_in,
554 total_tokens_out,
555 room: manifest.room.clone(),
556 };
557
558 let render = RenderConfig {
560 title: manifest.name.clone(),
561 theme: None,
562 sections: RenderConfig::default_sections(),
563 generate_preview: true,
564 };
565
566 let tool_usage = derive_tool_usage(
568 &side_effects,
569 &manifest.authorized_tools,
570 &manifest.network_scope,
571 );
572
573 SessionReceipt {
574 type_: RECEIPT_TYPE.into(),
575 schema_version: Some(RECEIPT_SCHEMA_VERSION.into()),
576 session,
577 participants,
578 hosts,
579 tools,
580 agent_graph,
581 timeline,
582 side_effects,
583 artifacts: artifact_entries,
584 proofs,
585 merkle: merkle_section,
586 render,
587 tool_usage,
588 authority: None,
592 custody,
593 }
594 }
595
596 pub fn to_canonical_json(receipt: &SessionReceipt) -> Result<Vec<u8>, serde_json::Error> {
601 serde_json::to_vec(receipt)
602 }
603
604 pub fn digest(receipt: &SessionReceipt) -> Result<String, serde_json::Error> {
606 let bytes = Self::to_canonical_json(receipt)?;
607 let hash = Sha256::digest(&bytes);
608 Ok(format!("sha256:{}", hex::encode(hash)))
609 }
610}
611
612fn compute_participants(graph: &AgentGraph, manifest: &SessionManifest) -> Participants {
615 use std::collections::BTreeSet;
616
617 let mut tool_runtimes: BTreeSet<String> = BTreeSet::new();
618 let total_agents = graph.nodes.len() as u32;
620 let spawned_subagents = graph.spawn_count();
621 let handoffs = graph.handoff_count();
622 let max_depth = graph.max_depth();
623 let host_ids = graph.host_ids();
624
625 for tool in &manifest.tools {
627 if let Some(ref rt) = tool.tool_runtime_id {
628 tool_runtimes.insert(rt.clone());
629 }
630 }
631
632 let root = graph
634 .nodes
635 .iter()
636 .filter(|n| n.depth == 0)
637 .min_by_key(|n| n.started_at.as_deref().unwrap_or(""))
638 .map(|n| n.agent_instance_id.clone());
639
640 let final_output = graph
642 .nodes
643 .iter()
644 .filter(|n| n.completed_at.is_some())
645 .max_by_key(|n| n.completed_at.as_deref().unwrap_or(""))
646 .map(|n| n.agent_instance_id.clone());
647
648 Participants {
649 root_agent_instance_id: root.or(manifest.participants.root_agent_instance_id.clone()),
650 final_output_agent_instance_id: final_output
651 .or(manifest.participants.final_output_agent_instance_id.clone()),
652 total_agents,
653 spawned_subagents,
654 handoffs,
655 max_depth,
656 hosts: host_ids.len() as u32,
657 tool_runtimes: tool_runtimes.len() as u32,
658 }
659}
660
661fn compute_hosts(events: &[SessionEvent], manifest_hosts: &[HostInfo]) -> Vec<HostInfo> {
662 use std::collections::BTreeMap;
663
664 let mut hosts: BTreeMap<String, HostInfo> = BTreeMap::new();
665
666 for h in manifest_hosts {
668 hosts.insert(h.host_id.clone(), h.clone());
669 }
670
671 for e in events {
673 hosts.entry(e.host_id.clone()).or_insert_with(|| HostInfo {
674 host_id: e.host_id.clone(),
675 hostname: None,
676 os: None,
677 arch: None,
678 });
679 }
680
681 hosts.into_values().collect()
682}
683
684fn compute_tools(events: &[SessionEvent], manifest_tools: &[ToolInfo]) -> Vec<ToolInfo> {
685 use std::collections::BTreeMap;
686
687 let mut tools: BTreeMap<String, ToolInfo> = BTreeMap::new();
688
689 for t in manifest_tools {
691 tools.insert(t.tool_id.clone(), t.clone());
692 }
693
694 for e in events {
696 if let super::event::EventType::AgentCalledTool { ref tool_name, .. } = e.event_type {
697 let entry = tools.entry(tool_name.clone()).or_insert_with(|| ToolInfo {
698 tool_id: tool_name.clone(),
699 tool_name: tool_name.clone(),
700 tool_runtime_id: e.tool_runtime_id.clone(),
701 invocation_count: 0,
702 });
703 entry.invocation_count += 1;
704 }
705 }
706
707 tools.into_values().collect()
708}
709
710fn build_merkle(artifacts: &[ArtifactEntry]) -> (MerkleSection, Option<MerkleTree>) {
711 if artifacts.is_empty() {
712 return (MerkleSection::default(), None);
713 }
714
715 let mut tree = MerkleTree::new();
716 for art in artifacts {
717 tree.append(&art.artifact_id);
718 }
719
720 let root = tree.root().map(|r| format!("mroot_{}", hex::encode(r)));
721
722 let inclusion_proofs: Vec<InclusionProofEntry> = artifacts
724 .iter()
725 .enumerate()
726 .filter_map(|(i, art)| {
727 tree.inclusion_proof(i).map(|proof| InclusionProofEntry {
728 artifact_id: art.artifact_id.clone(),
729 leaf_index: i,
730 proof,
731 })
732 })
733 .collect();
734
735 let section = MerkleSection {
736 leaf_count: artifacts.len(),
737 root,
738 checkpoint_id: None,
739 inclusion_proofs,
740 merkle_version: tree.version(),
741 };
742
743 (section, Some(tree))
744}
745
746pub fn parse_ship_id_from_actor(actor: &str) -> Option<String> {
749 let rest = actor.strip_prefix("ship://")?;
750 let id = rest.split('/').next().unwrap_or(rest);
752 if id.is_empty() {
753 None
754 } else {
755 Some(id.to_string())
756 }
757}
758
759const TOOL_ALIASES: &[(&str, &[&str])] = &[
809 ("read_file", &["read_file", "Read"]),
811 (
812 "write_file",
813 &[
814 "write_file",
815 "Write",
816 "Edit",
817 "MultiEdit",
818 "NotebookEdit",
819 "edit_file",
820 ],
821 ),
822 ("bash", &["bash", "Bash", "shell"]),
823 ("web_fetch", &["web_fetch", "WebFetch", "webfetch"]),
824];
825
826fn source_attributes_a_tool(source: Option<&str>) -> bool {
851 matches!(
852 source,
853 None | Some("hook") | Some("mcp") | Some("shell-wrap") | Some("session-event-cli"),
854 )
855}
856
857fn count_attributed<'a, F>(
860 items: usize,
861 source_at: F,
862 canonical: &str,
863 counts: &mut std::collections::BTreeMap<String, u32>,
864) where
865 F: Fn(usize) -> Option<&'a str>,
866{
867 let n: u32 = (0..items)
868 .filter(|i| source_attributes_a_tool(source_at(*i)))
869 .count() as u32;
870 if n > 0 {
871 *counts.entry(canonical.to_string()).or_insert(0) += n;
872 }
873}
874
875fn derive_tool_usage(
876 side_effects: &SideEffects,
877 authorized_tools: &[String],
878 network_scope: &[String],
879) -> Option<ToolUsage> {
880 use std::collections::BTreeMap;
881
882 let total_specialized = side_effects.files_read.len()
883 + side_effects.files_written.len()
884 + side_effects.processes.len()
885 + side_effects.network_connections.len();
886
887 if side_effects.tool_invocations.is_empty()
888 && total_specialized == 0
889 && authorized_tools.is_empty()
890 && network_scope.is_empty()
891 {
892 return None;
893 }
894
895 let mut counts: BTreeMap<String, u32> = BTreeMap::new();
896
897 for inv in &side_effects.tool_invocations {
904 *counts.entry(inv.tool_name.clone()).or_insert(0) += 1;
905 }
906
907 let fr = &side_effects.files_read;
912 count_attributed(
913 fr.len(),
914 |i| fr[i].source.as_deref(),
915 "read_file",
916 &mut counts,
917 );
918 let fw = &side_effects.files_written;
919 count_attributed(
920 fw.len(),
921 |i| fw[i].source.as_deref(),
922 "write_file",
923 &mut counts,
924 );
925 let pr = &side_effects.processes;
926 count_attributed(pr.len(), |i| pr[i].source.as_deref(), "bash", &mut counts);
927 if !side_effects.network_connections.is_empty() {
931 *counts.entry("web_fetch".to_string()).or_insert(0) +=
932 side_effects.network_connections.len() as u32;
933 }
934
935 let actual: Vec<ToolUsageEntry> = counts
936 .iter()
937 .map(|(name, &count)| ToolUsageEntry {
938 tool_name: name.clone(),
939 count,
940 })
941 .collect();
942
943 let unauthorized = if authorized_tools.is_empty() {
949 Vec::new()
950 } else {
951 let declared_set: std::collections::BTreeSet<&str> =
952 authorized_tools.iter().map(|s| s.as_str()).collect();
953 counts
954 .keys()
955 .filter(|actual_name| !is_authorized(actual_name, &declared_set))
956 .cloned()
957 .collect()
958 };
959
960 let network_off_scope: Vec<String> = if network_scope.is_empty() {
965 Vec::new()
966 } else {
967 let mut off: Vec<String> = side_effects
968 .network_connections
969 .iter()
970 .map(|c| c.destination.clone())
971 .filter(|d| !host_in_scope(d, network_scope))
972 .collect();
973 off.sort();
974 off.dedup();
975 off
976 };
977
978 Some(ToolUsage {
979 declared: authorized_tools.to_vec(),
980 actual,
981 unauthorized,
982 network_declared: network_scope.to_vec(),
983 network_off_scope,
984 })
985}
986
987fn is_authorized(actual_name: &str, declared_set: &std::collections::BTreeSet<&str>) -> bool {
992 if declared_set.contains(actual_name) {
994 return true;
995 }
996 for (canonical, aliases) in TOOL_ALIASES {
999 if *canonical == actual_name || aliases.contains(&actual_name) {
1000 for alias in *aliases {
1001 if declared_set.contains(*alias) {
1002 return true;
1003 }
1004 }
1005 return false;
1006 }
1007 }
1008 false
1009}
1010
1011fn event_type_label(et: &super::event::EventType) -> String {
1012 use super::event::EventType::*;
1013 match et {
1014 SessionStarted => "session.started",
1015 SessionClosed { .. } => "session.closed",
1016 AgentStarted { .. } => "agent.started",
1017 AgentSpawned { .. } => "agent.spawned",
1018 AgentHandoff { .. } => "agent.handoff",
1019 AgentCollaborated { .. } => "agent.collaborated",
1020 AgentReturned { .. } => "agent.returned",
1021 AgentCompleted { .. } => "agent.completed",
1022 AgentFailed { .. } => "agent.failed",
1023 AgentCalledTool { .. } => "agent.called_tool",
1024 AgentReadFile { .. } => "agent.read_file",
1025 AgentWroteFile { .. } => "agent.wrote_file",
1026 AgentOpenedPort { .. } => "agent.opened_port",
1027 AgentConnectedNetwork { .. } => "agent.connected_network",
1028 AgentStartedProcess { .. } => "agent.started_process",
1029 AgentCompletedProcess { .. } => "agent.completed_process",
1030 AgentDecision { .. } => "agent.decision",
1031 AgentNote { .. } => "agent.note",
1032 }
1033 .into()
1034}
1035
1036fn event_summary(et: &super::event::EventType) -> Option<String> {
1038 use super::event::EventType::*;
1039 match et {
1040 SessionStarted => Some("Session started".into()),
1041 SessionClosed { summary, .. } => summary.clone().or(Some("Session closed".into())),
1042 AgentSpawned { reason, .. } => reason.clone(),
1043 AgentHandoff {
1044 from_agent_instance_id,
1045 to_agent_instance_id,
1046 ..
1047 } => Some(format!(
1048 "{from_agent_instance_id} -> {to_agent_instance_id}"
1049 )),
1050 AgentNote { text } => text.clone(),
1051 AgentCalledTool { tool_name, .. } => Some(format!("Called {tool_name}")),
1052 AgentReadFile { file_path, .. } => Some(format!("Read {file_path}")),
1053 AgentWroteFile { file_path, .. } => Some(format!("Wrote {file_path}")),
1054 AgentOpenedPort { port, .. } => Some(format!("Opened port {port}")),
1055 AgentConnectedNetwork { destination, .. } => Some(format!("Connected to {destination}")),
1056 AgentStartedProcess { process_name, .. } => Some(format!("Started {process_name}")),
1057 AgentCompletedProcess {
1058 process_name,
1059 exit_code,
1060 ..
1061 } => Some(format!(
1062 "Completed {process_name} (exit {})",
1063 exit_code.unwrap_or(-1)
1064 )),
1065 AgentCompleted { termination_reason } => termination_reason
1066 .clone()
1067 .or(Some("Agent completed".into())),
1068 AgentFailed { reason } => reason.clone().or(Some("Agent failed".into())),
1069 AgentDecision {
1070 model,
1071 summary,
1072 provider,
1073 ..
1074 } => {
1075 let mut parts = Vec::new();
1076 if let Some(s) = summary {
1077 parts.push(s.clone());
1078 }
1079 if let Some(m) = model {
1080 parts.push(format!("model: {m}"));
1081 }
1082 if let Some(p) = provider {
1083 parts.push(format!("via {p}"));
1084 }
1085 if parts.is_empty() {
1086 Some("LLM decision".into())
1087 } else {
1088 Some(parts.join(" | "))
1089 }
1090 }
1091 _ => None,
1092 }
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097 use super::*;
1098 use crate::session::event::*;
1099
1100 fn make_manifest() -> SessionManifest {
1101 SessionManifest::new(
1102 "ssn_001".into(),
1103 "agent://test".into(),
1104 "2026-04-05T08:00:00Z".into(),
1105 1743843600000,
1106 )
1107 }
1108
1109 fn mk(seq: u64, inst: &str, et: EventType) -> SessionEvent {
1112 SessionEvent {
1113 session_id: "ssn_001".into(),
1114 event_id: format!("evt_{:016x}", seq),
1115 timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
1116 sequence_no: seq,
1117 trace_id: "trace_1".into(),
1118 span_id: format!("span_{seq}"),
1119 parent_span_id: None,
1120 agent_id: format!("agent://{inst}"),
1121 agent_instance_id: inst.into(),
1122 agent_name: inst.into(),
1123 agent_role: None,
1124 host_id: "host_1".into(),
1125 tool_runtime_id: None,
1126 event_type: et,
1127 artifact_ref: None,
1128 meta: None,
1129 }
1130 }
1131
1132 fn make_events() -> Vec<SessionEvent> {
1133 vec![
1134 mk(0, "root", EventType::SessionStarted),
1135 mk(
1136 1,
1137 "root",
1138 EventType::AgentStarted {
1139 parent_agent_instance_id: None,
1140 },
1141 ),
1142 mk(
1143 2,
1144 "worker",
1145 EventType::AgentSpawned {
1146 spawned_by_agent_instance_id: "root".into(),
1147 reason: Some("review".into()),
1148 },
1149 ),
1150 mk(
1151 3,
1152 "worker",
1153 EventType::AgentCalledTool {
1154 tool_name: "read_file".into(),
1155 tool_input_digest: None,
1156 tool_output_digest: None,
1157 duration_ms: Some(5),
1158 },
1159 ),
1160 mk(
1161 4,
1162 "worker",
1163 EventType::AgentWroteFile {
1164 file_path: "src/fix.rs".into(),
1165 digest: None,
1166 operation: None,
1167 additions: None,
1168 deletions: None,
1169 },
1170 ),
1171 mk(
1172 5,
1173 "worker",
1174 EventType::AgentCompleted {
1175 termination_reason: None,
1176 },
1177 ),
1178 mk(
1179 6,
1180 "root",
1181 EventType::SessionClosed {
1182 summary: Some("Done".into()),
1183 duration_ms: Some(360000),
1184 },
1185 ),
1186 ]
1187 }
1188
1189 #[test]
1190 fn compose_receipt() {
1191 let manifest = make_manifest();
1192 let events = make_events();
1193 let artifacts = vec![
1194 ArtifactEntry {
1195 artifact_id: "art_001".into(),
1196 payload_type: "action".into(),
1197 digest: None,
1198 signed_at: None,
1199 unchained: false,
1200 },
1201 ArtifactEntry {
1202 artifact_id: "art_002".into(),
1203 payload_type: "action".into(),
1204 digest: None,
1205 signed_at: None,
1206 unchained: false,
1207 },
1208 ];
1209
1210 let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
1211
1212 assert_eq!(receipt.type_, RECEIPT_TYPE);
1213 assert_eq!(receipt.session.id, "ssn_001");
1214 assert_eq!(receipt.timeline.len(), 7);
1215 assert_eq!(receipt.agent_graph.nodes.len(), 2); assert_eq!(receipt.side_effects.files_written.len(), 1);
1217 assert_eq!(receipt.merkle.leaf_count, 2);
1218 assert!(receipt.merkle.root.is_some());
1219 }
1220
1221 #[test]
1222 fn composed_receipt_mirrors_bound_workflow_reference() {
1223 let mut manifest = make_manifest();
1224 manifest.workflow_ref = Some("art_0123456789abcdef0123456789abcdef".into());
1225
1226 let receipt = ReceiptComposer::compose(&manifest, &make_events(), vec![]);
1227
1228 assert_eq!(
1229 receipt.session.workflow_ref.as_deref(),
1230 Some("art_0123456789abcdef0123456789abcdef")
1231 );
1232 let json = ReceiptComposer::to_canonical_json(&receipt).unwrap();
1233 assert!(String::from_utf8(json)
1234 .unwrap()
1235 .contains(r#""workflow_ref":"art_0123456789abcdef0123456789abcdef""#));
1236 }
1237
1238 #[test]
1239 fn new_receipts_carry_schema_version() {
1240 let manifest = make_manifest();
1241 let events = make_events();
1242 let artifacts = vec![ArtifactEntry {
1243 artifact_id: "art_001".into(),
1244 payload_type: "action".into(),
1245 digest: None,
1246 signed_at: None,
1247 unchained: false,
1248 }];
1249 let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
1250 assert_eq!(
1251 receipt.schema_version.as_deref(),
1252 Some(RECEIPT_SCHEMA_VERSION)
1253 );
1254 let json =
1256 String::from_utf8(ReceiptComposer::to_canonical_json(&receipt).unwrap()).unwrap();
1257 assert!(
1258 json.contains(r#""schema_version":"1""#),
1259 "missing schema_version: {json}"
1260 );
1261 }
1262
1263 #[test]
1264 fn legacy_receipt_without_schema_version_round_trips_byte_identical() {
1265 let manifest = make_manifest();
1270 let events = make_events();
1271 let artifacts = vec![ArtifactEntry {
1272 artifact_id: "art_001".into(),
1273 payload_type: "action".into(),
1274 digest: None,
1275 signed_at: None,
1276 unchained: false,
1277 }];
1278 let mut receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
1279 receipt.schema_version = None; let original = ReceiptComposer::to_canonical_json(&receipt).unwrap();
1282 let original_str = std::str::from_utf8(&original).unwrap();
1284 assert!(
1285 !original_str.contains("schema_version"),
1286 "schema_version must be skipped when None"
1287 );
1288
1289 let parsed: SessionReceipt = serde_json::from_slice(&original).unwrap();
1290 assert!(
1291 parsed.schema_version.is_none(),
1292 "legacy receipts must parse with schema_version=None"
1293 );
1294
1295 let reserialized = ReceiptComposer::to_canonical_json(&parsed).unwrap();
1296 assert_eq!(
1297 original, reserialized,
1298 "legacy receipt must round-trip byte-identical so package determinism check passes"
1299 );
1300 }
1301
1302 #[test]
1303 fn canonical_json_is_deterministic() {
1304 let manifest = make_manifest();
1305 let events = make_events();
1306 let artifacts = vec![ArtifactEntry {
1307 artifact_id: "art_001".into(),
1308 payload_type: "action".into(),
1309 digest: None,
1310 signed_at: None,
1311 unchained: false,
1312 }];
1313
1314 let r1 = ReceiptComposer::compose(&manifest, &events, artifacts.clone());
1315 let r2 = ReceiptComposer::compose(&manifest, &events, artifacts);
1316
1317 let j1 = ReceiptComposer::to_canonical_json(&r1).unwrap();
1318 let j2 = ReceiptComposer::to_canonical_json(&r2).unwrap();
1319 assert_eq!(j1, j2);
1320
1321 let d1 = ReceiptComposer::digest(&r1).unwrap();
1322 let d2 = ReceiptComposer::digest(&r2).unwrap();
1323 assert_eq!(d1, d2);
1324 }
1325
1326 fn manifest_with_authorized(tools: Vec<&str>) -> SessionManifest {
1336 let mut m = make_manifest();
1337 m.authorized_tools = tools.into_iter().map(String::from).collect();
1338 m
1339 }
1340
1341 #[test]
1342 fn cert_omitting_bash_flags_unauthorized_when_session_runs_bash() {
1343 let manifest = manifest_with_authorized(vec!["read_file", "write_file"]); let events = vec![
1348 mk(0, "root", EventType::SessionStarted),
1349 mk(
1350 1,
1351 "agent",
1352 EventType::AgentCompletedProcess {
1353 process_name: "rm -rf /".into(),
1354 exit_code: Some(0),
1355 duration_ms: Some(50),
1356 command: Some("rm -rf /".into()),
1357 },
1358 ),
1359 mk(
1360 2,
1361 "root",
1362 EventType::SessionClosed {
1363 summary: None,
1364 duration_ms: Some(1000),
1365 },
1366 ),
1367 ];
1368 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1369 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1370 assert!(
1371 tu.unauthorized.iter().any(|t| t == "bash"),
1372 "bash must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
1373 tu.unauthorized, tu.actual,
1374 );
1375 }
1376
1377 #[test]
1378 fn cert_omitting_write_flags_unauthorized_when_session_writes_file() {
1379 let manifest = manifest_with_authorized(vec!["read_file", "bash"]); let events = vec![
1381 mk(0, "root", EventType::SessionStarted),
1382 mk(
1383 1,
1384 "agent",
1385 EventType::AgentWroteFile {
1386 file_path: "src/secret.rs".into(),
1387 digest: None,
1388 operation: Some("modified".into()),
1389 additions: Some(10),
1390 deletions: Some(0),
1391 },
1392 ),
1393 mk(
1394 2,
1395 "root",
1396 EventType::SessionClosed {
1397 summary: None,
1398 duration_ms: Some(1000),
1399 },
1400 ),
1401 ];
1402 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1403 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1404 assert!(
1405 tu.unauthorized.iter().any(|t| t == "write_file"),
1406 "write_file must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
1407 tu.unauthorized, tu.actual,
1408 );
1409 }
1410
1411 #[test]
1412 fn cert_includes_read_write_bash_passes_clean_when_all_used() {
1413 let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]);
1414 let events = vec![
1415 mk(0, "root", EventType::SessionStarted),
1416 mk(
1417 1,
1418 "agent",
1419 EventType::AgentReadFile {
1420 file_path: "package.json".into(),
1421 digest: None,
1422 },
1423 ),
1424 mk(
1425 2,
1426 "agent",
1427 EventType::AgentWroteFile {
1428 file_path: "src/lib.rs".into(),
1429 digest: None,
1430 operation: Some("modified".into()),
1431 additions: Some(5),
1432 deletions: Some(2),
1433 },
1434 ),
1435 mk(
1436 3,
1437 "agent",
1438 EventType::AgentCompletedProcess {
1439 process_name: "bun test".into(),
1440 exit_code: Some(0),
1441 duration_ms: Some(2000),
1442 command: Some("bun test".into()),
1443 },
1444 ),
1445 mk(
1446 4,
1447 "root",
1448 EventType::SessionClosed {
1449 summary: None,
1450 duration_ms: Some(5000),
1451 },
1452 ),
1453 ];
1454 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1455 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1456 assert!(
1457 tu.unauthorized.is_empty(),
1458 "all tools declared in cert should pass clean; got unauthorized={:?}",
1459 tu.unauthorized,
1460 );
1461 let actual_names: std::collections::BTreeSet<String> =
1465 tu.actual.iter().map(|e| e.tool_name.clone()).collect();
1466 assert!(actual_names.contains("read_file"));
1467 assert!(actual_names.contains("write_file"));
1468 assert!(actual_names.contains("bash"));
1469 }
1470
1471 #[test]
1472 fn webfetch_unauthorized_flagged_when_cert_omits_it() {
1473 let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]); let events = vec![
1475 mk(0, "root", EventType::SessionStarted),
1476 mk(
1477 1,
1478 "agent",
1479 EventType::AgentConnectedNetwork {
1480 destination: "evil.example.com".into(),
1481 port: Some(443),
1482 },
1483 ),
1484 mk(
1485 2,
1486 "root",
1487 EventType::SessionClosed {
1488 summary: None,
1489 duration_ms: Some(1000),
1490 },
1491 ),
1492 ];
1493 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1494 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1495 assert!(
1496 tu.unauthorized.iter().any(|t| t == "web_fetch"),
1497 "web_fetch must be flagged as unauthorized when cert omits it; got unauthorized={:?}",
1498 tu.unauthorized,
1499 );
1500 }
1501
1502 fn manifest_with_network(scope: Vec<&str>) -> SessionManifest {
1503 let mut m = make_manifest();
1504 m.network_scope = scope.into_iter().map(String::from).collect();
1505 m
1506 }
1507
1508 fn net(seq: u64, host: &str) -> SessionEvent {
1509 mk(
1510 seq,
1511 "agent",
1512 EventType::AgentConnectedNetwork {
1513 destination: host.into(),
1514 port: Some(443),
1515 },
1516 )
1517 }
1518
1519 #[test]
1520 fn host_in_scope_matches_exact_wildcard_and_any() {
1521 let scope = vec!["api.example.com".to_string(), "*.internal.net".to_string()];
1522 assert!(host_in_scope("api.example.com", &scope));
1523 assert!(host_in_scope("API.Example.com.", &scope));
1524 assert!(host_in_scope("internal.net", &scope));
1525 assert!(host_in_scope("db.internal.net", &scope));
1526 assert!(!host_in_scope("evil.example.com", &scope));
1527 assert!(!host_in_scope("notinternal.net", &scope));
1528 assert!(!host_in_scope("", &scope));
1529 assert!(host_in_scope("anything.at.all", &["*".to_string()]));
1530 assert!(!host_in_scope("anything.at.all", &[]));
1531 }
1532
1533 #[test]
1534 fn declared_network_scope_flags_off_scope_destinations() {
1535 let manifest = manifest_with_network(vec!["api.example.com", "*.internal.net"]);
1536 let events = vec![
1537 mk(0, "root", EventType::SessionStarted),
1538 net(1, "api.example.com"),
1539 net(2, "db.internal.net"),
1540 net(3, "evil.example.com"),
1541 net(4, "evil.example.com"),
1542 mk(
1543 5,
1544 "root",
1545 EventType::SessionClosed {
1546 summary: None,
1547 duration_ms: Some(1000),
1548 },
1549 ),
1550 ];
1551 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1552 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1553 assert_eq!(
1554 tu.network_declared,
1555 vec!["api.example.com".to_string(), "*.internal.net".to_string()]
1556 );
1557 assert_eq!(tu.network_off_scope, vec!["evil.example.com".to_string()]);
1558 }
1559
1560 #[test]
1561 fn no_network_scope_means_no_judgement() {
1562 let manifest = make_manifest();
1563 let events = vec![
1564 mk(0, "root", EventType::SessionStarted),
1565 net(1, "evil.example.com"),
1566 mk(
1567 2,
1568 "root",
1569 EventType::SessionClosed {
1570 summary: None,
1571 duration_ms: Some(1000),
1572 },
1573 ),
1574 ];
1575 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1576 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1577 assert!(tu.network_declared.is_empty());
1578 assert!(
1579 tu.network_off_scope.is_empty(),
1580 "nothing declared, nothing judged"
1581 );
1582 assert_eq!(
1583 receipt.side_effects.network_connections.len(),
1584 1,
1585 "but the connection is recorded"
1586 );
1587 }
1588
1589 fn evt_with_source(event_type: EventType, source: &str) -> SessionEvent {
1592 let mut e = mk(99, "agent", event_type);
1593 e.meta = Some(serde_json::json!({"source": source}));
1594 e
1595 }
1596
1597 #[test]
1598 fn titlecase_cert_authorizes_canonical_snake_actuals_via_alias() {
1599 let manifest = manifest_with_authorized(vec!["Read", "Write", "Bash"]);
1602 let events = vec![
1603 mk(0, "root", EventType::SessionStarted),
1604 mk(
1605 1,
1606 "agent",
1607 EventType::AgentReadFile {
1608 file_path: "x".into(),
1609 digest: None,
1610 },
1611 ),
1612 mk(
1613 2,
1614 "agent",
1615 EventType::AgentWroteFile {
1616 file_path: "y".into(),
1617 digest: None,
1618 operation: None,
1619 additions: None,
1620 deletions: None,
1621 },
1622 ),
1623 mk(
1624 3,
1625 "agent",
1626 EventType::AgentCompletedProcess {
1627 process_name: "z".into(),
1628 exit_code: Some(0),
1629 duration_ms: Some(1),
1630 command: None,
1631 },
1632 ),
1633 mk(
1634 4,
1635 "root",
1636 EventType::SessionClosed {
1637 summary: None,
1638 duration_ms: Some(1000),
1639 },
1640 ),
1641 ];
1642 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1643 .tool_usage
1644 .unwrap();
1645 assert!(
1646 tu.unauthorized.is_empty(),
1647 "TitleCase declarations must authorize canonical snake_case actuals via aliases; \
1648 got unauthorized={:?}",
1649 tu.unauthorized,
1650 );
1651 }
1652
1653 #[test]
1654 fn edit_alias_authorizes_specialized_wrote_file() {
1655 let manifest = manifest_with_authorized(vec!["Edit"]);
1660 let events = vec![
1661 mk(0, "root", EventType::SessionStarted),
1662 mk(
1663 1,
1664 "agent",
1665 EventType::AgentWroteFile {
1666 file_path: "x".into(),
1667 digest: None,
1668 operation: None,
1669 additions: None,
1670 deletions: None,
1671 },
1672 ),
1673 mk(
1674 2,
1675 "root",
1676 EventType::SessionClosed {
1677 summary: None,
1678 duration_ms: Some(1000),
1679 },
1680 ),
1681 ];
1682 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1683 .tool_usage
1684 .unwrap();
1685 assert!(
1686 tu.unauthorized.is_empty(),
1687 "Edit alias must authorize write_file"
1688 );
1689 }
1690
1691 #[test]
1692 fn git_reconcile_writes_dont_count_toward_tool_usage() {
1693 let manifest = manifest_with_authorized(vec!["read_file"]);
1697 let events = vec![
1698 mk(0, "root", EventType::SessionStarted),
1699 evt_with_source(
1700 EventType::AgentWroteFile {
1701 file_path: "CHANGELOG.md".into(),
1702 digest: None,
1703 operation: Some("modified".into()),
1704 additions: Some(7),
1705 deletions: Some(2),
1706 },
1707 "git-reconcile",
1708 ),
1709 mk(
1710 2,
1711 "root",
1712 EventType::SessionClosed {
1713 summary: None,
1714 duration_ms: Some(1000),
1715 },
1716 ),
1717 ];
1718 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1719 .tool_usage
1720 .unwrap();
1721 assert!(
1722 !tu.unauthorized.iter().any(|t| t == "write_file"),
1723 "git-reconcile entries must NOT count toward tool_usage; \
1724 got unauthorized={:?}, actual={:?}",
1725 tu.unauthorized,
1726 tu.actual,
1727 );
1728 let actual_names: std::collections::BTreeSet<String> =
1729 tu.actual.iter().map(|e| e.tool_name.clone()).collect();
1730 assert!(
1731 !actual_names.contains("write_file"),
1732 "actual must not include backstop-only writes"
1733 );
1734 }
1735
1736 #[test]
1745 fn hook_emitted_writes_still_count_toward_tool_usage() {
1746 let manifest = manifest_with_authorized(vec!["read_file"]); let events = vec![
1749 mk(0, "root", EventType::SessionStarted),
1750 evt_with_source(
1751 EventType::AgentWroteFile {
1752 file_path: "src/x.rs".into(),
1753 digest: None,
1754 operation: None,
1755 additions: None,
1756 deletions: None,
1757 },
1758 "hook",
1759 ),
1760 mk(
1761 2,
1762 "root",
1763 EventType::SessionClosed {
1764 summary: None,
1765 duration_ms: Some(1000),
1766 },
1767 ),
1768 ];
1769 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1770 .tool_usage
1771 .unwrap();
1772 assert!(
1773 tu.unauthorized.iter().any(|t| t == "write_file"),
1774 "hook-emitted writes MUST count toward tool_usage; got unauthorized={:?}",
1775 tu.unauthorized,
1776 );
1777 }
1778
1779 #[test]
1780 fn legacy_untagged_writes_count_for_back_compat() {
1781 let manifest = manifest_with_authorized(vec!["read_file"]); let events = vec![
1785 mk(0, "root", EventType::SessionStarted),
1786 mk(
1787 1,
1788 "agent",
1789 EventType::AgentWroteFile {
1790 file_path: "x".into(),
1791 digest: None,
1792 operation: None,
1793 additions: None,
1794 deletions: None,
1795 },
1796 ),
1797 mk(
1798 2,
1799 "root",
1800 EventType::SessionClosed {
1801 summary: None,
1802 duration_ms: Some(1000),
1803 },
1804 ),
1805 ];
1806 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1807 .tool_usage
1808 .unwrap();
1809 assert!(
1810 tu.unauthorized.iter().any(|t| t == "write_file"),
1811 "legacy untagged writes must count for back-compat",
1812 );
1813 }
1814}
1815
1816#[cfg(test)]
1817mod custody_tests {
1818 use super::*;
1819
1820 #[test]
1823 fn self_custody_serializes_to_nothing() {
1824 let c: Option<Custody> = None;
1825 let json = serde_json::to_string(&serde_json::json!({ "custody": c })).unwrap();
1826 assert_eq!(json, r#"{"custody":null}"#);
1827 #[derive(Serialize)]
1829 struct Holder {
1830 #[serde(default, skip_serializing_if = "Option::is_none")]
1831 custody: Option<Custody>,
1832 }
1833 let s = serde_json::to_string(&Holder { custody: None }).unwrap();
1834 assert_eq!(s, "{}", "self-custody must add no bytes");
1835 }
1836
1837 #[test]
1841 fn delegated_custody_names_signer_and_subject() {
1842 let c = Custody::delegated("svc://gateway-rooms", "agent://fizz")
1843 .with_reason("browser-mediated room; participants hold no local key");
1844 let v = serde_json::to_value(&c).unwrap();
1845 assert_eq!(v["mode"], "delegated");
1846 assert_eq!(v["signer"], "svc://gateway-rooms");
1847 assert_eq!(v["on_behalf_of"], "agent://fizz");
1848 assert!(v["reason"].as_str().unwrap().contains("no local key"));
1849 }
1850
1851 #[test]
1852 fn reason_is_optional_and_omitted_when_unset() {
1853 let c = Custody::delegated("svc://x", "agent://y");
1854 let v = serde_json::to_value(&c).unwrap();
1855 assert!(v.get("reason").is_none(), "unset reason must not serialize");
1856 }
1857
1858 #[test]
1861 fn custody_round_trips() {
1862 let c = Custody::delegated("svc://gateway-rooms", "agent://fizz").with_reason("r");
1863 let back: Custody = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
1864 assert_eq!(c, back);
1865 }
1866
1867 #[test]
1871 fn custody_is_orthogonal_to_evidence_capture() {
1872 let receipt = serde_json::json!({
1873 "attestation_class": "runtime",
1874 "custody": Custody::delegated("svc://gateway-rooms", "agent://fizz"),
1875 });
1876 assert_eq!(receipt["attestation_class"], "runtime");
1877 assert_eq!(receipt["custody"]["mode"], "delegated");
1878 }
1879}
1880
1881#[cfg(test)]
1882mod custody_wiring_tests {
1883 use super::*;
1884
1885 #[test]
1891 fn a_delegated_receipt_passes_predicate_validation() {
1892 let payload = serde_json::json!({
1893 "session_id": "ssn_room_demo",
1894 "actor": "agent://fizz",
1895 "outcome": "completed",
1896 "started_at": "2026-08-10T10:00:00Z",
1897 "closed_at": "2026-08-10T10:30:00Z",
1898 "attestation_class": "runtime",
1899 "receipt_digest": format!("sha256:{}", "a".repeat(64)),
1900 "custody": {
1901 "mode": "delegated",
1902 "signer": "svc://gateway-rooms",
1903 "on_behalf_of": "agent://fizz",
1904 "reason": "browser-mediated room; participants hold no local key"
1905 }
1906 });
1907 crate::predicates::validate("session.v1", Some(&payload))
1908 .expect("a delegated-custody receipt must validate");
1909 }
1910
1911 #[test]
1913 fn a_self_custody_receipt_still_validates() {
1914 let payload = serde_json::json!({
1915 "session_id": "ssn_plain",
1916 "actor": "ship://local",
1917 "outcome": "completed",
1918 "started_at": "2026-08-10T10:00:00Z",
1919 "closed_at": "2026-08-10T10:30:00Z",
1920 "attestation_class": "self",
1921 "receipt_digest": format!("sha256:{}", "b".repeat(64)),
1922 });
1923 crate::predicates::validate("session.v1", Some(&payload))
1924 .expect("a self-custody receipt must validate");
1925 }
1926
1927 #[test]
1938 fn custody_requires_a_signer_by_type_and_by_validator() {
1939 let c = Custody::delegated("svc://gateway-rooms", "agent://fizz");
1941 assert!(!c.signer.is_empty());
1942 assert!(!c.on_behalf_of.is_empty());
1943
1944 let payload = serde_json::json!({
1946 "session_id": "ssn_bad",
1947 "actor": "agent://fizz",
1948 "outcome": "completed",
1949 "started_at": "2026-08-10T10:00:00Z",
1950 "closed_at": "2026-08-10T10:30:00Z",
1951 "attestation_class": "self",
1952 "receipt_digest": format!("sha256:{}", "c".repeat(64)),
1953 "custody": { "mode": "delegated", "on_behalf_of": "agent://fizz" }
1954 });
1955 let err = crate::predicates::validate("session.v1", Some(&payload))
1956 .expect_err("a custody block without a signer must be refused before signing");
1957 assert!(
1958 err.to_string().contains("custody.signer"),
1959 "the refusal names the nested field: {err}"
1960 );
1961 }
1962
1963 #[test]
1967 fn none_custody_composes_identically() {
1968 let m = SessionManifest::new(
1969 "ssn_x".into(),
1970 "ship://local".into(),
1971 "2026-08-10T10:00:00Z".into(),
1972 1_760_000_000_000,
1973 );
1974 let a = ReceiptComposer::compose(&m, &[], Vec::new());
1975 let b = ReceiptComposer::compose_with_custody(&m, &[], Vec::new(), None);
1976 assert_eq!(
1977 serde_json::to_string(&a).unwrap(),
1978 serde_json::to_string(&b).unwrap()
1979 );
1980 }
1981
1982 #[test]
1983 fn delegated_custody_reaches_the_composed_receipt() {
1984 let m = SessionManifest::new(
1985 "ssn_y".into(),
1986 "agent://fizz".into(),
1987 "2026-08-10T10:00:00Z".into(),
1988 1_760_000_000_000,
1989 );
1990 let r = ReceiptComposer::compose_with_custody(
1991 &m,
1992 &[],
1993 Vec::new(),
1994 Some(Custody::delegated("svc://gateway-rooms", "agent://fizz")),
1995 );
1996 let v = serde_json::to_value(&r).unwrap();
1997 assert_eq!(v["custody"]["signer"], "svc://gateway-rooms");
1998 assert_eq!(v["custody"]["on_behalf_of"], "agent://fizz");
1999 }
2000}