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