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")]
248 pub workflow_ref: Option<String>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub narrative: Option<Narrative>,
252 #[serde(default)]
254 pub total_tokens_in: u64,
255 #[serde(default)]
257 pub total_tokens_out: u64,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub room: Option<RoomInfo>,
265}
266
267#[derive(Debug, Clone, Default, Serialize, Deserialize)]
269pub struct Narrative {
270 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub headline: Option<String>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub summary: Option<String>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub review: Option<String>,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct TimelineEntry {
284 pub sequence_no: u64,
285 pub timestamp: String,
286 pub event_id: String,
287 pub event_type: String,
288 pub agent_instance_id: String,
289 pub agent_name: String,
290 pub host_id: String,
291 #[serde(skip_serializing_if = "Option::is_none")]
292 pub summary: Option<String>,
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct ArtifactEntry {
298 pub artifact_id: String,
299 pub payload_type: String,
300 #[serde(skip_serializing_if = "Option::is_none")]
301 pub digest: Option<String>,
302 #[serde(skip_serializing_if = "Option::is_none")]
303 pub signed_at: Option<String>,
304 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
309 pub unchained: bool,
310}
311
312#[derive(Debug, Clone, Default, Serialize, Deserialize)]
314pub struct ProofsSection {
315 #[serde(default)]
316 pub signature_count: u32,
317 #[serde(default)]
318 pub signatures_valid: bool,
319 #[serde(default)]
320 pub merkle_root_valid: bool,
321 #[serde(default)]
322 pub inclusion_proofs_count: u32,
323 #[serde(default)]
324 pub zk_proofs_present: bool,
325 #[serde(default, skip_serializing_if = "is_zero_u32")]
334 pub event_log_skipped: u32,
335 #[serde(default, skip_serializing_if = "is_zero_u32")]
336 pub reconcile_untracked_truncated: u32,
337 #[serde(default, skip_serializing_if = "is_zero_u32")]
338 pub reconcile_untracked_cap: u32,
339 #[serde(default, skip_serializing_if = "is_false")]
347 pub reconcile_degraded: bool,
348}
349
350fn is_zero_u32(n: &u32) -> bool {
351 *n == 0
352}
353fn is_false(b: &bool) -> bool {
354 !*b
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct MerkleSection {
360 pub leaf_count: usize,
361 #[serde(skip_serializing_if = "Option::is_none")]
362 pub root: Option<String>,
363 #[serde(skip_serializing_if = "Option::is_none")]
364 pub checkpoint_id: Option<String>,
365 #[serde(default, skip_serializing_if = "Vec::is_empty")]
366 pub inclusion_proofs: Vec<InclusionProofEntry>,
367 #[serde(default = "crate::merkle::tree::default_merkle_version_v1")]
372 pub merkle_version: u8,
373}
374
375impl Default for MerkleSection {
376 fn default() -> Self {
377 Self {
381 leaf_count: 0,
382 root: None,
383 checkpoint_id: None,
384 inclusion_proofs: Vec::new(),
385 merkle_version: crate::merkle::tree::MERKLE_VERSION_V2,
386 }
387 }
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct InclusionProofEntry {
393 pub artifact_id: String,
394 pub leaf_index: usize,
395 pub proof: InclusionProof,
396}
397
398pub struct ReceiptComposer;
402
403impl ReceiptComposer {
404 pub fn compose(
406 manifest: &SessionManifest,
407 events: &[SessionEvent],
408 artifact_entries: Vec<ArtifactEntry>,
409 ) -> SessionReceipt {
410 Self::compose_with_custody(manifest, events, artifact_entries, None)
411 }
412
413 pub fn compose_with_custody(
427 manifest: &SessionManifest,
428 events: &[SessionEvent],
429 artifact_entries: Vec<ArtifactEntry>,
430 custody: Option<Custody>,
431 ) -> SessionReceipt {
432 let agent_graph = AgentGraph::from_events(events);
434
435 let side_effects = SideEffects::from_events(events);
437
438 let mut timeline: Vec<TimelineEntry> = events
440 .iter()
441 .map(|e| TimelineEntry {
442 sequence_no: e.sequence_no,
443 timestamp: e.timestamp.clone(),
444 event_id: e.event_id.clone(),
445 event_type: event_type_label(&e.event_type),
446 agent_instance_id: e.agent_instance_id.clone(),
447 agent_name: e.agent_name.clone(),
448 host_id: e.host_id.clone(),
449 summary: event_summary(&e.event_type),
450 })
451 .collect();
452
453 timeline.sort_by(|a, b| {
455 a.timestamp
456 .cmp(&b.timestamp)
457 .then(a.sequence_no.cmp(&b.sequence_no))
458 .then(a.event_id.cmp(&b.event_id))
459 });
460
461 let participants = compute_participants(&agent_graph, manifest);
463
464 let hosts = compute_hosts(events, &manifest.hosts);
466 let tools = compute_tools(events, &manifest.tools);
467
468 let duration_ms = events.iter().find_map(|e| {
470 if let super::event::EventType::SessionClosed { duration_ms, .. } = &e.event_type {
471 *duration_ms
472 } else {
473 None
474 }
475 });
476
477 let (merkle_section, merkle_tree) = build_merkle(&artifact_entries);
479
480 let proofs = ProofsSection {
484 signature_count: artifact_entries.len() as u32,
485 signatures_valid: false,
492 merkle_root_valid: merkle_tree.is_some(),
493 inclusion_proofs_count: merkle_section.inclusion_proofs.len() as u32,
494 zk_proofs_present: false,
495 event_log_skipped: 0, reconcile_untracked_truncated: 0,
497 reconcile_untracked_cap: 0,
498 reconcile_degraded: false, };
500
501 let total_tokens_in: u64 = agent_graph.nodes.iter().map(|n| n.tokens_in).sum();
504 let total_tokens_out: u64 = agent_graph.nodes.iter().map(|n| n.tokens_out).sum();
505
506 let session = SessionSection {
508 id: manifest.session_id.clone(),
509 name: manifest.name.clone(),
510 mode: manifest.mode.clone(),
511 started_at: manifest.started_at.clone(),
512 ended_at: manifest.closed_at.clone(),
513 status: manifest.status.clone(),
514 duration_ms,
515 ship_id: parse_ship_id_from_actor(&manifest.actor),
516 workflow_ref: manifest.workflow_ref.clone(),
517 narrative: manifest.summary.as_ref().map(|s| Narrative {
518 headline: manifest.name.clone(),
519 summary: Some(s.clone()),
520 review: None,
521 }),
522 total_tokens_in,
523 total_tokens_out,
524 room: manifest.room.clone(),
525 };
526
527 let render = RenderConfig {
529 title: manifest.name.clone(),
530 theme: None,
531 sections: RenderConfig::default_sections(),
532 generate_preview: true,
533 };
534
535 let tool_usage = derive_tool_usage(&side_effects, &manifest.authorized_tools);
537
538 SessionReceipt {
539 type_: RECEIPT_TYPE.into(),
540 schema_version: Some(RECEIPT_SCHEMA_VERSION.into()),
541 session,
542 participants,
543 hosts,
544 tools,
545 agent_graph,
546 timeline,
547 side_effects,
548 artifacts: artifact_entries,
549 proofs,
550 merkle: merkle_section,
551 render,
552 tool_usage,
553 authority: None,
557 custody,
558 }
559 }
560
561 pub fn to_canonical_json(receipt: &SessionReceipt) -> Result<Vec<u8>, serde_json::Error> {
566 serde_json::to_vec(receipt)
567 }
568
569 pub fn digest(receipt: &SessionReceipt) -> Result<String, serde_json::Error> {
571 let bytes = Self::to_canonical_json(receipt)?;
572 let hash = Sha256::digest(&bytes);
573 Ok(format!("sha256:{}", hex::encode(hash)))
574 }
575}
576
577fn compute_participants(graph: &AgentGraph, manifest: &SessionManifest) -> Participants {
580 use std::collections::BTreeSet;
581
582 let mut tool_runtimes: BTreeSet<String> = BTreeSet::new();
583 let total_agents = graph.nodes.len() as u32;
585 let spawned_subagents = graph.spawn_count();
586 let handoffs = graph.handoff_count();
587 let max_depth = graph.max_depth();
588 let host_ids = graph.host_ids();
589
590 for tool in &manifest.tools {
592 if let Some(ref rt) = tool.tool_runtime_id {
593 tool_runtimes.insert(rt.clone());
594 }
595 }
596
597 let root = graph
599 .nodes
600 .iter()
601 .filter(|n| n.depth == 0)
602 .min_by_key(|n| n.started_at.as_deref().unwrap_or(""))
603 .map(|n| n.agent_instance_id.clone());
604
605 let final_output = graph
607 .nodes
608 .iter()
609 .filter(|n| n.completed_at.is_some())
610 .max_by_key(|n| n.completed_at.as_deref().unwrap_or(""))
611 .map(|n| n.agent_instance_id.clone());
612
613 Participants {
614 root_agent_instance_id: root.or(manifest.participants.root_agent_instance_id.clone()),
615 final_output_agent_instance_id: final_output
616 .or(manifest.participants.final_output_agent_instance_id.clone()),
617 total_agents,
618 spawned_subagents,
619 handoffs,
620 max_depth,
621 hosts: host_ids.len() as u32,
622 tool_runtimes: tool_runtimes.len() as u32,
623 }
624}
625
626fn compute_hosts(events: &[SessionEvent], manifest_hosts: &[HostInfo]) -> Vec<HostInfo> {
627 use std::collections::BTreeMap;
628
629 let mut hosts: BTreeMap<String, HostInfo> = BTreeMap::new();
630
631 for h in manifest_hosts {
633 hosts.insert(h.host_id.clone(), h.clone());
634 }
635
636 for e in events {
638 hosts.entry(e.host_id.clone()).or_insert_with(|| HostInfo {
639 host_id: e.host_id.clone(),
640 hostname: None,
641 os: None,
642 arch: None,
643 });
644 }
645
646 hosts.into_values().collect()
647}
648
649fn compute_tools(events: &[SessionEvent], manifest_tools: &[ToolInfo]) -> Vec<ToolInfo> {
650 use std::collections::BTreeMap;
651
652 let mut tools: BTreeMap<String, ToolInfo> = BTreeMap::new();
653
654 for t in manifest_tools {
656 tools.insert(t.tool_id.clone(), t.clone());
657 }
658
659 for e in events {
661 if let super::event::EventType::AgentCalledTool { ref tool_name, .. } = e.event_type {
662 let entry = tools.entry(tool_name.clone()).or_insert_with(|| ToolInfo {
663 tool_id: tool_name.clone(),
664 tool_name: tool_name.clone(),
665 tool_runtime_id: e.tool_runtime_id.clone(),
666 invocation_count: 0,
667 });
668 entry.invocation_count += 1;
669 }
670 }
671
672 tools.into_values().collect()
673}
674
675fn build_merkle(artifacts: &[ArtifactEntry]) -> (MerkleSection, Option<MerkleTree>) {
676 if artifacts.is_empty() {
677 return (MerkleSection::default(), None);
678 }
679
680 let mut tree = MerkleTree::new();
681 for art in artifacts {
682 tree.append(&art.artifact_id);
683 }
684
685 let root = tree.root().map(|r| format!("mroot_{}", hex::encode(r)));
686
687 let inclusion_proofs: Vec<InclusionProofEntry> = artifacts
689 .iter()
690 .enumerate()
691 .filter_map(|(i, art)| {
692 tree.inclusion_proof(i).map(|proof| InclusionProofEntry {
693 artifact_id: art.artifact_id.clone(),
694 leaf_index: i,
695 proof,
696 })
697 })
698 .collect();
699
700 let section = MerkleSection {
701 leaf_count: artifacts.len(),
702 root,
703 checkpoint_id: None,
704 inclusion_proofs,
705 merkle_version: tree.version(),
706 };
707
708 (section, Some(tree))
709}
710
711pub fn parse_ship_id_from_actor(actor: &str) -> Option<String> {
714 let rest = actor.strip_prefix("ship://")?;
715 let id = rest.split('/').next().unwrap_or(rest);
717 if id.is_empty() {
718 None
719 } else {
720 Some(id.to_string())
721 }
722}
723
724const TOOL_ALIASES: &[(&str, &[&str])] = &[
774 ("read_file", &["read_file", "Read"]),
776 (
777 "write_file",
778 &[
779 "write_file",
780 "Write",
781 "Edit",
782 "MultiEdit",
783 "NotebookEdit",
784 "edit_file",
785 ],
786 ),
787 ("bash", &["bash", "Bash", "shell"]),
788 ("web_fetch", &["web_fetch", "WebFetch", "webfetch"]),
789];
790
791fn source_attributes_a_tool(source: Option<&str>) -> bool {
816 matches!(
817 source,
818 None | Some("hook") | Some("mcp") | Some("shell-wrap") | Some("session-event-cli"),
819 )
820}
821
822fn count_attributed<'a, F>(
825 items: usize,
826 source_at: F,
827 canonical: &str,
828 counts: &mut std::collections::BTreeMap<String, u32>,
829) where
830 F: Fn(usize) -> Option<&'a str>,
831{
832 let n: u32 = (0..items)
833 .filter(|i| source_attributes_a_tool(source_at(*i)))
834 .count() as u32;
835 if n > 0 {
836 *counts.entry(canonical.to_string()).or_insert(0) += n;
837 }
838}
839
840fn derive_tool_usage(side_effects: &SideEffects, authorized_tools: &[String]) -> Option<ToolUsage> {
841 use std::collections::BTreeMap;
842
843 let total_specialized = side_effects.files_read.len()
844 + side_effects.files_written.len()
845 + side_effects.processes.len()
846 + side_effects.network_connections.len();
847
848 if side_effects.tool_invocations.is_empty()
849 && total_specialized == 0
850 && authorized_tools.is_empty()
851 {
852 return None;
853 }
854
855 let mut counts: BTreeMap<String, u32> = BTreeMap::new();
856
857 for inv in &side_effects.tool_invocations {
864 *counts.entry(inv.tool_name.clone()).or_insert(0) += 1;
865 }
866
867 let fr = &side_effects.files_read;
872 count_attributed(
873 fr.len(),
874 |i| fr[i].source.as_deref(),
875 "read_file",
876 &mut counts,
877 );
878 let fw = &side_effects.files_written;
879 count_attributed(
880 fw.len(),
881 |i| fw[i].source.as_deref(),
882 "write_file",
883 &mut counts,
884 );
885 let pr = &side_effects.processes;
886 count_attributed(pr.len(), |i| pr[i].source.as_deref(), "bash", &mut counts);
887 if !side_effects.network_connections.is_empty() {
891 *counts.entry("web_fetch".to_string()).or_insert(0) +=
892 side_effects.network_connections.len() as u32;
893 }
894
895 let actual: Vec<ToolUsageEntry> = counts
896 .iter()
897 .map(|(name, &count)| ToolUsageEntry {
898 tool_name: name.clone(),
899 count,
900 })
901 .collect();
902
903 let unauthorized = if authorized_tools.is_empty() {
909 Vec::new()
910 } else {
911 let declared_set: std::collections::BTreeSet<&str> =
912 authorized_tools.iter().map(|s| s.as_str()).collect();
913 counts
914 .keys()
915 .filter(|actual_name| !is_authorized(actual_name, &declared_set))
916 .cloned()
917 .collect()
918 };
919
920 Some(ToolUsage {
921 declared: authorized_tools.to_vec(),
922 actual,
923 unauthorized,
924 })
925}
926
927fn is_authorized(actual_name: &str, declared_set: &std::collections::BTreeSet<&str>) -> bool {
932 if declared_set.contains(actual_name) {
934 return true;
935 }
936 for (canonical, aliases) in TOOL_ALIASES {
939 if *canonical == actual_name || aliases.contains(&actual_name) {
940 for alias in *aliases {
941 if declared_set.contains(*alias) {
942 return true;
943 }
944 }
945 return false;
946 }
947 }
948 false
949}
950
951fn event_type_label(et: &super::event::EventType) -> String {
952 use super::event::EventType::*;
953 match et {
954 SessionStarted => "session.started",
955 SessionClosed { .. } => "session.closed",
956 AgentStarted { .. } => "agent.started",
957 AgentSpawned { .. } => "agent.spawned",
958 AgentHandoff { .. } => "agent.handoff",
959 AgentCollaborated { .. } => "agent.collaborated",
960 AgentReturned { .. } => "agent.returned",
961 AgentCompleted { .. } => "agent.completed",
962 AgentFailed { .. } => "agent.failed",
963 AgentCalledTool { .. } => "agent.called_tool",
964 AgentReadFile { .. } => "agent.read_file",
965 AgentWroteFile { .. } => "agent.wrote_file",
966 AgentOpenedPort { .. } => "agent.opened_port",
967 AgentConnectedNetwork { .. } => "agent.connected_network",
968 AgentStartedProcess { .. } => "agent.started_process",
969 AgentCompletedProcess { .. } => "agent.completed_process",
970 AgentDecision { .. } => "agent.decision",
971 }
972 .into()
973}
974
975fn event_summary(et: &super::event::EventType) -> Option<String> {
977 use super::event::EventType::*;
978 match et {
979 SessionStarted => Some("Session started".into()),
980 SessionClosed { summary, .. } => summary.clone().or(Some("Session closed".into())),
981 AgentSpawned { reason, .. } => reason.clone(),
982 AgentHandoff {
983 from_agent_instance_id,
984 to_agent_instance_id,
985 ..
986 } => Some(format!(
987 "{from_agent_instance_id} -> {to_agent_instance_id}"
988 )),
989 AgentCalledTool { tool_name, .. } => Some(format!("Called {tool_name}")),
990 AgentReadFile { file_path, .. } => Some(format!("Read {file_path}")),
991 AgentWroteFile { file_path, .. } => Some(format!("Wrote {file_path}")),
992 AgentOpenedPort { port, .. } => Some(format!("Opened port {port}")),
993 AgentConnectedNetwork { destination, .. } => Some(format!("Connected to {destination}")),
994 AgentStartedProcess { process_name, .. } => Some(format!("Started {process_name}")),
995 AgentCompletedProcess {
996 process_name,
997 exit_code,
998 ..
999 } => Some(format!(
1000 "Completed {process_name} (exit {})",
1001 exit_code.unwrap_or(-1)
1002 )),
1003 AgentCompleted { termination_reason } => termination_reason
1004 .clone()
1005 .or(Some("Agent completed".into())),
1006 AgentFailed { reason } => reason.clone().or(Some("Agent failed".into())),
1007 AgentDecision {
1008 model,
1009 summary,
1010 provider,
1011 ..
1012 } => {
1013 let mut parts = Vec::new();
1014 if let Some(s) = summary {
1015 parts.push(s.clone());
1016 }
1017 if let Some(m) = model {
1018 parts.push(format!("model: {m}"));
1019 }
1020 if let Some(p) = provider {
1021 parts.push(format!("via {p}"));
1022 }
1023 if parts.is_empty() {
1024 Some("LLM decision".into())
1025 } else {
1026 Some(parts.join(" | "))
1027 }
1028 }
1029 _ => None,
1030 }
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035 use super::*;
1036 use crate::session::event::*;
1037
1038 fn make_manifest() -> SessionManifest {
1039 SessionManifest::new(
1040 "ssn_001".into(),
1041 "agent://test".into(),
1042 "2026-04-05T08:00:00Z".into(),
1043 1743843600000,
1044 )
1045 }
1046
1047 fn mk(seq: u64, inst: &str, et: EventType) -> SessionEvent {
1050 SessionEvent {
1051 session_id: "ssn_001".into(),
1052 event_id: format!("evt_{:016x}", seq),
1053 timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
1054 sequence_no: seq,
1055 trace_id: "trace_1".into(),
1056 span_id: format!("span_{seq}"),
1057 parent_span_id: None,
1058 agent_id: format!("agent://{inst}"),
1059 agent_instance_id: inst.into(),
1060 agent_name: inst.into(),
1061 agent_role: None,
1062 host_id: "host_1".into(),
1063 tool_runtime_id: None,
1064 event_type: et,
1065 artifact_ref: None,
1066 meta: None,
1067 }
1068 }
1069
1070 fn make_events() -> Vec<SessionEvent> {
1071 vec![
1072 mk(0, "root", EventType::SessionStarted),
1073 mk(
1074 1,
1075 "root",
1076 EventType::AgentStarted {
1077 parent_agent_instance_id: None,
1078 },
1079 ),
1080 mk(
1081 2,
1082 "worker",
1083 EventType::AgentSpawned {
1084 spawned_by_agent_instance_id: "root".into(),
1085 reason: Some("review".into()),
1086 },
1087 ),
1088 mk(
1089 3,
1090 "worker",
1091 EventType::AgentCalledTool {
1092 tool_name: "read_file".into(),
1093 tool_input_digest: None,
1094 tool_output_digest: None,
1095 duration_ms: Some(5),
1096 },
1097 ),
1098 mk(
1099 4,
1100 "worker",
1101 EventType::AgentWroteFile {
1102 file_path: "src/fix.rs".into(),
1103 digest: None,
1104 operation: None,
1105 additions: None,
1106 deletions: None,
1107 },
1108 ),
1109 mk(
1110 5,
1111 "worker",
1112 EventType::AgentCompleted {
1113 termination_reason: None,
1114 },
1115 ),
1116 mk(
1117 6,
1118 "root",
1119 EventType::SessionClosed {
1120 summary: Some("Done".into()),
1121 duration_ms: Some(360000),
1122 },
1123 ),
1124 ]
1125 }
1126
1127 #[test]
1128 fn compose_receipt() {
1129 let manifest = make_manifest();
1130 let events = make_events();
1131 let artifacts = vec![
1132 ArtifactEntry {
1133 artifact_id: "art_001".into(),
1134 payload_type: "action".into(),
1135 digest: None,
1136 signed_at: None,
1137 unchained: false,
1138 },
1139 ArtifactEntry {
1140 artifact_id: "art_002".into(),
1141 payload_type: "action".into(),
1142 digest: None,
1143 signed_at: None,
1144 unchained: false,
1145 },
1146 ];
1147
1148 let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
1149
1150 assert_eq!(receipt.type_, RECEIPT_TYPE);
1151 assert_eq!(receipt.session.id, "ssn_001");
1152 assert_eq!(receipt.timeline.len(), 7);
1153 assert_eq!(receipt.agent_graph.nodes.len(), 2); assert_eq!(receipt.side_effects.files_written.len(), 1);
1155 assert_eq!(receipt.merkle.leaf_count, 2);
1156 assert!(receipt.merkle.root.is_some());
1157 }
1158
1159 #[test]
1160 fn composed_receipt_mirrors_bound_workflow_reference() {
1161 let mut manifest = make_manifest();
1162 manifest.workflow_ref = Some("art_0123456789abcdef0123456789abcdef".into());
1163
1164 let receipt = ReceiptComposer::compose(&manifest, &make_events(), vec![]);
1165
1166 assert_eq!(
1167 receipt.session.workflow_ref.as_deref(),
1168 Some("art_0123456789abcdef0123456789abcdef")
1169 );
1170 let json = ReceiptComposer::to_canonical_json(&receipt).unwrap();
1171 assert!(String::from_utf8(json)
1172 .unwrap()
1173 .contains(r#""workflow_ref":"art_0123456789abcdef0123456789abcdef""#));
1174 }
1175
1176 #[test]
1177 fn new_receipts_carry_schema_version() {
1178 let manifest = make_manifest();
1179 let events = make_events();
1180 let artifacts = vec![ArtifactEntry {
1181 artifact_id: "art_001".into(),
1182 payload_type: "action".into(),
1183 digest: None,
1184 signed_at: None,
1185 unchained: false,
1186 }];
1187 let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
1188 assert_eq!(
1189 receipt.schema_version.as_deref(),
1190 Some(RECEIPT_SCHEMA_VERSION)
1191 );
1192 let json =
1194 String::from_utf8(ReceiptComposer::to_canonical_json(&receipt).unwrap()).unwrap();
1195 assert!(
1196 json.contains(r#""schema_version":"1""#),
1197 "missing schema_version: {json}"
1198 );
1199 }
1200
1201 #[test]
1202 fn legacy_receipt_without_schema_version_round_trips_byte_identical() {
1203 let manifest = make_manifest();
1208 let events = make_events();
1209 let artifacts = vec![ArtifactEntry {
1210 artifact_id: "art_001".into(),
1211 payload_type: "action".into(),
1212 digest: None,
1213 signed_at: None,
1214 unchained: false,
1215 }];
1216 let mut receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
1217 receipt.schema_version = None; let original = ReceiptComposer::to_canonical_json(&receipt).unwrap();
1220 let original_str = std::str::from_utf8(&original).unwrap();
1222 assert!(
1223 !original_str.contains("schema_version"),
1224 "schema_version must be skipped when None"
1225 );
1226
1227 let parsed: SessionReceipt = serde_json::from_slice(&original).unwrap();
1228 assert!(
1229 parsed.schema_version.is_none(),
1230 "legacy receipts must parse with schema_version=None"
1231 );
1232
1233 let reserialized = ReceiptComposer::to_canonical_json(&parsed).unwrap();
1234 assert_eq!(
1235 original, reserialized,
1236 "legacy receipt must round-trip byte-identical so package determinism check passes"
1237 );
1238 }
1239
1240 #[test]
1241 fn canonical_json_is_deterministic() {
1242 let manifest = make_manifest();
1243 let events = make_events();
1244 let artifacts = vec![ArtifactEntry {
1245 artifact_id: "art_001".into(),
1246 payload_type: "action".into(),
1247 digest: None,
1248 signed_at: None,
1249 unchained: false,
1250 }];
1251
1252 let r1 = ReceiptComposer::compose(&manifest, &events, artifacts.clone());
1253 let r2 = ReceiptComposer::compose(&manifest, &events, artifacts);
1254
1255 let j1 = ReceiptComposer::to_canonical_json(&r1).unwrap();
1256 let j2 = ReceiptComposer::to_canonical_json(&r2).unwrap();
1257 assert_eq!(j1, j2);
1258
1259 let d1 = ReceiptComposer::digest(&r1).unwrap();
1260 let d2 = ReceiptComposer::digest(&r2).unwrap();
1261 assert_eq!(d1, d2);
1262 }
1263
1264 fn manifest_with_authorized(tools: Vec<&str>) -> SessionManifest {
1274 let mut m = make_manifest();
1275 m.authorized_tools = tools.into_iter().map(String::from).collect();
1276 m
1277 }
1278
1279 #[test]
1280 fn cert_omitting_bash_flags_unauthorized_when_session_runs_bash() {
1281 let manifest = manifest_with_authorized(vec!["read_file", "write_file"]); let events = vec![
1286 mk(0, "root", EventType::SessionStarted),
1287 mk(
1288 1,
1289 "agent",
1290 EventType::AgentCompletedProcess {
1291 process_name: "rm -rf /".into(),
1292 exit_code: Some(0),
1293 duration_ms: Some(50),
1294 command: Some("rm -rf /".into()),
1295 },
1296 ),
1297 mk(
1298 2,
1299 "root",
1300 EventType::SessionClosed {
1301 summary: None,
1302 duration_ms: Some(1000),
1303 },
1304 ),
1305 ];
1306 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1307 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1308 assert!(
1309 tu.unauthorized.iter().any(|t| t == "bash"),
1310 "bash must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
1311 tu.unauthorized, tu.actual,
1312 );
1313 }
1314
1315 #[test]
1316 fn cert_omitting_write_flags_unauthorized_when_session_writes_file() {
1317 let manifest = manifest_with_authorized(vec!["read_file", "bash"]); let events = vec![
1319 mk(0, "root", EventType::SessionStarted),
1320 mk(
1321 1,
1322 "agent",
1323 EventType::AgentWroteFile {
1324 file_path: "src/secret.rs".into(),
1325 digest: None,
1326 operation: Some("modified".into()),
1327 additions: Some(10),
1328 deletions: Some(0),
1329 },
1330 ),
1331 mk(
1332 2,
1333 "root",
1334 EventType::SessionClosed {
1335 summary: None,
1336 duration_ms: Some(1000),
1337 },
1338 ),
1339 ];
1340 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1341 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1342 assert!(
1343 tu.unauthorized.iter().any(|t| t == "write_file"),
1344 "write_file must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
1345 tu.unauthorized, tu.actual,
1346 );
1347 }
1348
1349 #[test]
1350 fn cert_includes_read_write_bash_passes_clean_when_all_used() {
1351 let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]);
1352 let events = vec![
1353 mk(0, "root", EventType::SessionStarted),
1354 mk(
1355 1,
1356 "agent",
1357 EventType::AgentReadFile {
1358 file_path: "package.json".into(),
1359 digest: None,
1360 },
1361 ),
1362 mk(
1363 2,
1364 "agent",
1365 EventType::AgentWroteFile {
1366 file_path: "src/lib.rs".into(),
1367 digest: None,
1368 operation: Some("modified".into()),
1369 additions: Some(5),
1370 deletions: Some(2),
1371 },
1372 ),
1373 mk(
1374 3,
1375 "agent",
1376 EventType::AgentCompletedProcess {
1377 process_name: "bun test".into(),
1378 exit_code: Some(0),
1379 duration_ms: Some(2000),
1380 command: Some("bun test".into()),
1381 },
1382 ),
1383 mk(
1384 4,
1385 "root",
1386 EventType::SessionClosed {
1387 summary: None,
1388 duration_ms: Some(5000),
1389 },
1390 ),
1391 ];
1392 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1393 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1394 assert!(
1395 tu.unauthorized.is_empty(),
1396 "all tools declared in cert should pass clean; got unauthorized={:?}",
1397 tu.unauthorized,
1398 );
1399 let actual_names: std::collections::BTreeSet<String> =
1403 tu.actual.iter().map(|e| e.tool_name.clone()).collect();
1404 assert!(actual_names.contains("read_file"));
1405 assert!(actual_names.contains("write_file"));
1406 assert!(actual_names.contains("bash"));
1407 }
1408
1409 #[test]
1410 fn webfetch_unauthorized_flagged_when_cert_omits_it() {
1411 let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]); let events = vec![
1413 mk(0, "root", EventType::SessionStarted),
1414 mk(
1415 1,
1416 "agent",
1417 EventType::AgentConnectedNetwork {
1418 destination: "evil.example.com".into(),
1419 port: Some(443),
1420 },
1421 ),
1422 mk(
1423 2,
1424 "root",
1425 EventType::SessionClosed {
1426 summary: None,
1427 duration_ms: Some(1000),
1428 },
1429 ),
1430 ];
1431 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1432 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1433 assert!(
1434 tu.unauthorized.iter().any(|t| t == "web_fetch"),
1435 "web_fetch must be flagged as unauthorized when cert omits it; got unauthorized={:?}",
1436 tu.unauthorized,
1437 );
1438 }
1439
1440 fn evt_with_source(event_type: EventType, source: &str) -> SessionEvent {
1443 let mut e = mk(99, "agent", event_type);
1444 e.meta = Some(serde_json::json!({"source": source}));
1445 e
1446 }
1447
1448 #[test]
1449 fn titlecase_cert_authorizes_canonical_snake_actuals_via_alias() {
1450 let manifest = manifest_with_authorized(vec!["Read", "Write", "Bash"]);
1453 let events = vec![
1454 mk(0, "root", EventType::SessionStarted),
1455 mk(
1456 1,
1457 "agent",
1458 EventType::AgentReadFile {
1459 file_path: "x".into(),
1460 digest: None,
1461 },
1462 ),
1463 mk(
1464 2,
1465 "agent",
1466 EventType::AgentWroteFile {
1467 file_path: "y".into(),
1468 digest: None,
1469 operation: None,
1470 additions: None,
1471 deletions: None,
1472 },
1473 ),
1474 mk(
1475 3,
1476 "agent",
1477 EventType::AgentCompletedProcess {
1478 process_name: "z".into(),
1479 exit_code: Some(0),
1480 duration_ms: Some(1),
1481 command: None,
1482 },
1483 ),
1484 mk(
1485 4,
1486 "root",
1487 EventType::SessionClosed {
1488 summary: None,
1489 duration_ms: Some(1000),
1490 },
1491 ),
1492 ];
1493 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1494 .tool_usage
1495 .unwrap();
1496 assert!(
1497 tu.unauthorized.is_empty(),
1498 "TitleCase declarations must authorize canonical snake_case actuals via aliases; \
1499 got unauthorized={:?}",
1500 tu.unauthorized,
1501 );
1502 }
1503
1504 #[test]
1505 fn edit_alias_authorizes_specialized_wrote_file() {
1506 let manifest = manifest_with_authorized(vec!["Edit"]);
1511 let events = vec![
1512 mk(0, "root", EventType::SessionStarted),
1513 mk(
1514 1,
1515 "agent",
1516 EventType::AgentWroteFile {
1517 file_path: "x".into(),
1518 digest: None,
1519 operation: None,
1520 additions: None,
1521 deletions: None,
1522 },
1523 ),
1524 mk(
1525 2,
1526 "root",
1527 EventType::SessionClosed {
1528 summary: None,
1529 duration_ms: Some(1000),
1530 },
1531 ),
1532 ];
1533 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1534 .tool_usage
1535 .unwrap();
1536 assert!(
1537 tu.unauthorized.is_empty(),
1538 "Edit alias must authorize write_file"
1539 );
1540 }
1541
1542 #[test]
1543 fn git_reconcile_writes_dont_count_toward_tool_usage() {
1544 let manifest = manifest_with_authorized(vec!["read_file"]);
1548 let events = vec![
1549 mk(0, "root", EventType::SessionStarted),
1550 evt_with_source(
1551 EventType::AgentWroteFile {
1552 file_path: "CHANGELOG.md".into(),
1553 digest: None,
1554 operation: Some("modified".into()),
1555 additions: Some(7),
1556 deletions: Some(2),
1557 },
1558 "git-reconcile",
1559 ),
1560 mk(
1561 2,
1562 "root",
1563 EventType::SessionClosed {
1564 summary: None,
1565 duration_ms: Some(1000),
1566 },
1567 ),
1568 ];
1569 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1570 .tool_usage
1571 .unwrap();
1572 assert!(
1573 !tu.unauthorized.iter().any(|t| t == "write_file"),
1574 "git-reconcile entries must NOT count toward tool_usage; \
1575 got unauthorized={:?}, actual={:?}",
1576 tu.unauthorized,
1577 tu.actual,
1578 );
1579 let actual_names: std::collections::BTreeSet<String> =
1580 tu.actual.iter().map(|e| e.tool_name.clone()).collect();
1581 assert!(
1582 !actual_names.contains("write_file"),
1583 "actual must not include backstop-only writes"
1584 );
1585 }
1586
1587 #[test]
1596 fn hook_emitted_writes_still_count_toward_tool_usage() {
1597 let manifest = manifest_with_authorized(vec!["read_file"]); let events = vec![
1600 mk(0, "root", EventType::SessionStarted),
1601 evt_with_source(
1602 EventType::AgentWroteFile {
1603 file_path: "src/x.rs".into(),
1604 digest: None,
1605 operation: None,
1606 additions: None,
1607 deletions: None,
1608 },
1609 "hook",
1610 ),
1611 mk(
1612 2,
1613 "root",
1614 EventType::SessionClosed {
1615 summary: None,
1616 duration_ms: Some(1000),
1617 },
1618 ),
1619 ];
1620 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1621 .tool_usage
1622 .unwrap();
1623 assert!(
1624 tu.unauthorized.iter().any(|t| t == "write_file"),
1625 "hook-emitted writes MUST count toward tool_usage; got unauthorized={:?}",
1626 tu.unauthorized,
1627 );
1628 }
1629
1630 #[test]
1631 fn legacy_untagged_writes_count_for_back_compat() {
1632 let manifest = manifest_with_authorized(vec!["read_file"]); let events = vec![
1636 mk(0, "root", EventType::SessionStarted),
1637 mk(
1638 1,
1639 "agent",
1640 EventType::AgentWroteFile {
1641 file_path: "x".into(),
1642 digest: None,
1643 operation: None,
1644 additions: None,
1645 deletions: None,
1646 },
1647 ),
1648 mk(
1649 2,
1650 "root",
1651 EventType::SessionClosed {
1652 summary: None,
1653 duration_ms: Some(1000),
1654 },
1655 ),
1656 ];
1657 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1658 .tool_usage
1659 .unwrap();
1660 assert!(
1661 tu.unauthorized.iter().any(|t| t == "write_file"),
1662 "legacy untagged writes must count for back-compat",
1663 );
1664 }
1665}
1666
1667#[cfg(test)]
1668mod custody_tests {
1669 use super::*;
1670
1671 #[test]
1674 fn self_custody_serializes_to_nothing() {
1675 let c: Option<Custody> = None;
1676 let json = serde_json::to_string(&serde_json::json!({ "custody": c })).unwrap();
1677 assert_eq!(json, r#"{"custody":null}"#);
1678 #[derive(Serialize)]
1680 struct Holder {
1681 #[serde(default, skip_serializing_if = "Option::is_none")]
1682 custody: Option<Custody>,
1683 }
1684 let s = serde_json::to_string(&Holder { custody: None }).unwrap();
1685 assert_eq!(s, "{}", "self-custody must add no bytes");
1686 }
1687
1688 #[test]
1692 fn delegated_custody_names_signer_and_subject() {
1693 let c = Custody::delegated("svc://gateway-rooms", "agent://fizz")
1694 .with_reason("browser-mediated room; participants hold no local key");
1695 let v = serde_json::to_value(&c).unwrap();
1696 assert_eq!(v["mode"], "delegated");
1697 assert_eq!(v["signer"], "svc://gateway-rooms");
1698 assert_eq!(v["on_behalf_of"], "agent://fizz");
1699 assert!(v["reason"].as_str().unwrap().contains("no local key"));
1700 }
1701
1702 #[test]
1703 fn reason_is_optional_and_omitted_when_unset() {
1704 let c = Custody::delegated("svc://x", "agent://y");
1705 let v = serde_json::to_value(&c).unwrap();
1706 assert!(v.get("reason").is_none(), "unset reason must not serialize");
1707 }
1708
1709 #[test]
1712 fn custody_round_trips() {
1713 let c = Custody::delegated("svc://gateway-rooms", "agent://fizz").with_reason("r");
1714 let back: Custody = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
1715 assert_eq!(c, back);
1716 }
1717
1718 #[test]
1722 fn custody_is_orthogonal_to_evidence_capture() {
1723 let receipt = serde_json::json!({
1724 "attestation_class": "runtime",
1725 "custody": Custody::delegated("svc://gateway-rooms", "agent://fizz"),
1726 });
1727 assert_eq!(receipt["attestation_class"], "runtime");
1728 assert_eq!(receipt["custody"]["mode"], "delegated");
1729 }
1730}
1731
1732#[cfg(test)]
1733mod custody_wiring_tests {
1734 use super::*;
1735
1736 #[test]
1742 fn a_delegated_receipt_passes_predicate_validation() {
1743 let payload = serde_json::json!({
1744 "session_id": "ssn_room_demo",
1745 "actor": "agent://fizz",
1746 "outcome": "completed",
1747 "started_at": "2026-08-10T10:00:00Z",
1748 "closed_at": "2026-08-10T10:30:00Z",
1749 "attestation_class": "runtime",
1750 "receipt_digest": format!("sha256:{}", "a".repeat(64)),
1751 "custody": {
1752 "mode": "delegated",
1753 "signer": "svc://gateway-rooms",
1754 "on_behalf_of": "agent://fizz",
1755 "reason": "browser-mediated room; participants hold no local key"
1756 }
1757 });
1758 crate::predicates::validate("session.v1", Some(&payload))
1759 .expect("a delegated-custody receipt must validate");
1760 }
1761
1762 #[test]
1764 fn a_self_custody_receipt_still_validates() {
1765 let payload = serde_json::json!({
1766 "session_id": "ssn_plain",
1767 "actor": "ship://local",
1768 "outcome": "completed",
1769 "started_at": "2026-08-10T10:00:00Z",
1770 "closed_at": "2026-08-10T10:30:00Z",
1771 "attestation_class": "self",
1772 "receipt_digest": format!("sha256:{}", "b".repeat(64)),
1773 });
1774 crate::predicates::validate("session.v1", Some(&payload))
1775 .expect("a self-custody receipt must validate");
1776 }
1777
1778 #[test]
1793 fn custody_requires_a_signer_by_type_not_by_validator() {
1794 let c = Custody::delegated("svc://gateway-rooms", "agent://fizz");
1796 assert!(!c.signer.is_empty());
1797 assert!(!c.on_behalf_of.is_empty());
1798
1799 let payload = serde_json::json!({
1802 "session_id": "ssn_bad",
1803 "actor": "agent://fizz",
1804 "outcome": "completed",
1805 "started_at": "2026-08-10T10:00:00Z",
1806 "closed_at": "2026-08-10T10:30:00Z",
1807 "attestation_class": "self",
1808 "receipt_digest": format!("sha256:{}", "c".repeat(64)),
1809 "custody": { "mode": "delegated", "on_behalf_of": "agent://fizz" }
1810 });
1811 assert!(
1812 crate::predicates::validate("session.v1", Some(&payload)).is_ok(),
1813 "core validates top-level fields only; if this starts failing the \
1814 validator gained nested checking and the doc comment above is stale"
1815 );
1816 }
1817
1818 #[test]
1822 fn none_custody_composes_identically() {
1823 let m = SessionManifest::new(
1824 "ssn_x".into(),
1825 "ship://local".into(),
1826 "2026-08-10T10:00:00Z".into(),
1827 1_760_000_000_000,
1828 );
1829 let a = ReceiptComposer::compose(&m, &[], Vec::new());
1830 let b = ReceiptComposer::compose_with_custody(&m, &[], Vec::new(), None);
1831 assert_eq!(
1832 serde_json::to_string(&a).unwrap(),
1833 serde_json::to_string(&b).unwrap()
1834 );
1835 }
1836
1837 #[test]
1838 fn delegated_custody_reaches_the_composed_receipt() {
1839 let m = SessionManifest::new(
1840 "ssn_y".into(),
1841 "agent://fizz".into(),
1842 "2026-08-10T10:00:00Z".into(),
1843 1_760_000_000_000,
1844 );
1845 let r = ReceiptComposer::compose_with_custody(
1846 &m,
1847 &[],
1848 Vec::new(),
1849 Some(Custody::delegated("svc://gateway-rooms", "agent://fizz")),
1850 );
1851 let v = serde_json::to_value(&r).unwrap();
1852 assert_eq!(v["custody"]["signer"], "svc://gateway-rooms");
1853 assert_eq!(v["custody"]["on_behalf_of"], "agent://fizz");
1854 }
1855}