Skip to main content

treeship_core/session/
receipt.rs

1//! Session Receipt composer.
2//!
3//! Builds the canonical Session Receipt JSON from session events,
4//! artifact store, and Merkle tree. The receipt is the composed
5//! package-level artifact that unifies an entire session.
6
7use serde::{Deserialize, Serialize};
8use sha2::{Sha256, Digest};
9
10use crate::merkle::{MerkleTree, InclusionProof};
11
12use super::event::SessionEvent;
13use super::graph::AgentGraph;
14use super::manifest::{
15    HostInfo, LifecycleMode, Participants, SessionManifest, SessionStatus, ToolInfo,
16};
17use super::render::RenderConfig;
18use super::side_effects::SideEffects;
19
20/// Receipt type identifier.
21pub const RECEIPT_TYPE: &str = "treeship/session-receipt/v1";
22
23/// Current receipt schema version. Receipts without this field are treated
24/// as schema "0" and verified under legacy rules (pre-v0.9.0 shape).
25pub const RECEIPT_SCHEMA_VERSION: &str = "1";
26
27// ── Top-level receipt ────────────────────────────────────────────────
28
29/// The complete Session Receipt.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct SessionReceipt {
32    /// Always "treeship/session-receipt/v1".
33    #[serde(rename = "type")]
34    pub type_: String,
35
36    /// Schema version. Absent on pre-v0.9.0 receipts (treated as "0").
37    /// Set to "1" for v0.9.0+ receipts.
38    #[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    /// Tool usage summary: declared vs actual tools used during the session.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub tool_usage: Option<ToolUsage>,
55}
56
57/// Tool authorization and usage summary for the session.
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct ToolUsage {
60    /// Tools declared as authorized (from declaration.json).
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub declared: Vec<String>,
63    /// Tools actually called during the session with invocation counts.
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub actual: Vec<ToolUsageEntry>,
66    /// Tools called that were NOT in the declared list.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub unauthorized: Vec<String>,
69}
70
71/// A single tool's usage count.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ToolUsageEntry {
74    pub tool_name: String,
75    pub count: u32,
76}
77
78/// Session metadata section of the receipt.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct SessionSection {
81    pub id: String,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub name: Option<String>,
84    pub mode: LifecycleMode,
85    pub started_at: String,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub ended_at: Option<String>,
88    pub status: SessionStatus,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub duration_ms: Option<u64>,
91    /// Ship ID this session ran under, parsed from the manifest actor URI
92    /// (`ship://<ship_id>`). Absent on pre-v0.9.0 receipts or when the actor
93    /// URI was not a ship:// URI (e.g. human://alice for a human-led session).
94    /// Cross-verification uses this to check that a receipt and a presented
95    /// Agent Certificate reference the same ship.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub ship_id: Option<String>,
98    /// Structured narrative for human review. All fields optional.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub narrative: Option<Narrative>,
101    /// Cumulative input tokens across all agents.
102    #[serde(default)]
103    pub total_tokens_in: u64,
104    /// Cumulative output tokens across all agents.
105    #[serde(default)]
106    pub total_tokens_out: u64,
107}
108
109
110/// Structured narrative for the session summary.
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112pub struct Narrative {
113    /// One-line headline: "Verifier refactor completed."
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub headline: Option<String>,
116    /// Multi-sentence summary of what happened.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub summary: Option<String>,
119    /// What should be reviewed before trusting the output.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub review: Option<String>,
122}
123
124/// A single timeline entry.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct TimelineEntry {
127    pub sequence_no: u64,
128    pub timestamp: String,
129    pub event_id: String,
130    pub event_type: String,
131    pub agent_instance_id: String,
132    pub agent_name: String,
133    pub host_id: String,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub summary: Option<String>,
136}
137
138/// An artifact referenced in the session.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct ArtifactEntry {
141    pub artifact_id: String,
142    pub payload_type: String,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub digest: Option<String>,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub signed_at: Option<String>,
147}
148
149/// Proofs section of the receipt.
150#[derive(Debug, Clone, Default, Serialize, Deserialize)]
151pub struct ProofsSection {
152    #[serde(default)]
153    pub signature_count: u32,
154    #[serde(default)]
155    pub signatures_valid: bool,
156    #[serde(default)]
157    pub merkle_root_valid: bool,
158    #[serde(default)]
159    pub inclusion_proofs_count: u32,
160    #[serde(default)]
161    pub zk_proofs_present: bool,
162}
163
164/// Merkle section of the receipt.
165#[derive(Debug, Clone, Default, Serialize, Deserialize)]
166pub struct MerkleSection {
167    pub leaf_count: usize,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub root: Option<String>,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub checkpoint_id: Option<String>,
172    #[serde(default, skip_serializing_if = "Vec::is_empty")]
173    pub inclusion_proofs: Vec<InclusionProofEntry>,
174}
175
176/// A Merkle inclusion proof entry.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct InclusionProofEntry {
179    pub artifact_id: String,
180    pub leaf_index: usize,
181    pub proof: InclusionProof,
182}
183
184// ── Composer ─────────────────────────────────────────────────────────
185
186/// Composes a Session Receipt from events and artifacts.
187pub struct ReceiptComposer;
188
189impl ReceiptComposer {
190    /// Compose a receipt from a session manifest, events, and optional artifact entries.
191    pub fn compose(
192        manifest: &SessionManifest,
193        events: &[SessionEvent],
194        artifact_entries: Vec<ArtifactEntry>,
195    ) -> SessionReceipt {
196        // Build agent graph
197        let agent_graph = AgentGraph::from_events(events);
198
199        // Build side effects
200        let side_effects = SideEffects::from_events(events);
201
202        // Build timeline from all events
203        let mut timeline: Vec<TimelineEntry> = events.iter().map(|e| {
204            TimelineEntry {
205                sequence_no: e.sequence_no,
206                timestamp: e.timestamp.clone(),
207                event_id: e.event_id.clone(),
208                event_type: event_type_label(&e.event_type),
209                agent_instance_id: e.agent_instance_id.clone(),
210                agent_name: e.agent_name.clone(),
211                host_id: e.host_id.clone(),
212                summary: event_summary(&e.event_type),
213            }
214        }).collect();
215
216        // Sort by (timestamp, sequence_no, event_id) for determinism
217        timeline.sort_by(|a, b| {
218            a.timestamp.cmp(&b.timestamp)
219                .then(a.sequence_no.cmp(&b.sequence_no))
220                .then(a.event_id.cmp(&b.event_id))
221        });
222
223        // Compute participants from graph
224        let participants = compute_participants(&agent_graph, manifest);
225
226        // Compute hosts and tools from events
227        let hosts = compute_hosts(events, &manifest.hosts);
228        let tools = compute_tools(events, &manifest.tools);
229
230        // Compute duration from the session close event if present
231        let duration_ms = events.iter().find_map(|e| {
232            if let super::event::EventType::SessionClosed { duration_ms, .. } = &e.event_type {
233                *duration_ms
234            } else {
235                None
236            }
237        });
238
239        // Build Merkle tree from artifact IDs
240        let (merkle_section, merkle_tree) = build_merkle(&artifact_entries);
241
242        // Proofs section. zk_proofs_present defaults to false here;
243        // the CLI caller sets it to true after compose if proof files
244        // exist in the session directory.
245        let proofs = ProofsSection {
246            signature_count: artifact_entries.len() as u32,
247            signatures_valid: true, // Caller should verify
248            merkle_root_valid: merkle_tree.is_some(),
249            inclusion_proofs_count: merkle_section.inclusion_proofs.len() as u32,
250            zk_proofs_present: false,
251        };
252
253        // Compute cost/token totals from agent graph
254        // Cost is deliberately not aggregated. See event.rs comment.
255        let total_tokens_in: u64 = agent_graph.nodes.iter().map(|n| n.tokens_in).sum();
256        let total_tokens_out: u64 = agent_graph.nodes.iter().map(|n| n.tokens_out).sum();
257
258        // Session section
259        let session = SessionSection {
260            id: manifest.session_id.clone(),
261            name: manifest.name.clone(),
262            mode: manifest.mode.clone(),
263            started_at: manifest.started_at.clone(),
264            ended_at: manifest.closed_at.clone(),
265            status: manifest.status.clone(),
266            duration_ms,
267            ship_id: parse_ship_id_from_actor(&manifest.actor),
268            narrative: manifest.summary.as_ref().map(|s| Narrative {
269                headline: manifest.name.clone(),
270                summary: Some(s.clone()),
271                review: None,
272            }),
273            total_tokens_in,
274            total_tokens_out,
275        };
276
277        // Render config
278        let render = RenderConfig {
279            title: manifest.name.clone(),
280            theme: None,
281            sections: RenderConfig::default_sections(),
282            generate_preview: true,
283        };
284
285        // Derive tool usage from side effects + manifest authorized_tools
286        let tool_usage = derive_tool_usage(&side_effects, &manifest.authorized_tools);
287
288        SessionReceipt {
289            type_: RECEIPT_TYPE.into(),
290            schema_version: Some(RECEIPT_SCHEMA_VERSION.into()),
291            session,
292            participants,
293            hosts,
294            tools,
295            agent_graph,
296            timeline,
297            side_effects,
298            artifacts: artifact_entries,
299            proofs,
300            merkle: merkle_section,
301            render,
302            tool_usage,
303        }
304    }
305
306    /// Produce deterministic canonical JSON bytes from a receipt.
307    ///
308    /// Uses serde's field-declaration-order serialization for determinism.
309    /// The resulting bytes are suitable for hashing.
310    pub fn to_canonical_json(receipt: &SessionReceipt) -> Result<Vec<u8>, serde_json::Error> {
311        serde_json::to_vec(receipt)
312    }
313
314    /// Compute SHA-256 digest of the canonical receipt JSON.
315    pub fn digest(receipt: &SessionReceipt) -> Result<String, serde_json::Error> {
316        let bytes = Self::to_canonical_json(receipt)?;
317        let hash = Sha256::digest(&bytes);
318        Ok(format!("sha256:{}", hex::encode(hash)))
319    }
320}
321
322// ── Helpers ──────────────────────────────────────────────────────────
323
324fn compute_participants(graph: &AgentGraph, manifest: &SessionManifest) -> Participants {
325    use std::collections::BTreeSet;
326
327    let mut tool_runtimes: BTreeSet<String> = BTreeSet::new();
328    // Count unique agents
329    let total_agents = graph.nodes.len() as u32;
330    let spawned_subagents = graph.spawn_count();
331    let handoffs = graph.handoff_count();
332    let max_depth = graph.max_depth();
333    let host_ids = graph.host_ids();
334
335    // Collect tool runtimes from events in manifest
336    for tool in &manifest.tools {
337        if let Some(ref rt) = tool.tool_runtime_id {
338            tool_runtimes.insert(rt.clone());
339        }
340    }
341
342    // Find root agent (depth 0, first started)
343    let root = graph.nodes.iter()
344        .filter(|n| n.depth == 0)
345        .min_by_key(|n| n.started_at.as_deref().unwrap_or(""))
346        .map(|n| n.agent_instance_id.clone());
347
348    // Find final output agent (last completed at max depth or last completed overall)
349    let final_output = graph.nodes.iter()
350        .filter(|n| n.completed_at.is_some())
351        .max_by_key(|n| n.completed_at.as_deref().unwrap_or(""))
352        .map(|n| n.agent_instance_id.clone());
353
354    Participants {
355        root_agent_instance_id: root.or(manifest.participants.root_agent_instance_id.clone()),
356        final_output_agent_instance_id: final_output.or(manifest.participants.final_output_agent_instance_id.clone()),
357        total_agents,
358        spawned_subagents,
359        handoffs,
360        max_depth,
361        hosts: host_ids.len() as u32,
362        tool_runtimes: tool_runtimes.len() as u32,
363    }
364}
365
366fn compute_hosts(events: &[SessionEvent], manifest_hosts: &[HostInfo]) -> Vec<HostInfo> {
367    use std::collections::BTreeMap;
368
369    let mut hosts: BTreeMap<String, HostInfo> = BTreeMap::new();
370
371    // Seed from manifest
372    for h in manifest_hosts {
373        hosts.insert(h.host_id.clone(), h.clone());
374    }
375
376    // Discover from events
377    for e in events {
378        hosts.entry(e.host_id.clone()).or_insert_with(|| HostInfo {
379            host_id: e.host_id.clone(),
380            hostname: None,
381            os: None,
382            arch: None,
383        });
384    }
385
386    hosts.into_values().collect()
387}
388
389fn compute_tools(events: &[SessionEvent], manifest_tools: &[ToolInfo]) -> Vec<ToolInfo> {
390    use std::collections::BTreeMap;
391
392    let mut tools: BTreeMap<String, ToolInfo> = BTreeMap::new();
393
394    // Seed from manifest
395    for t in manifest_tools {
396        tools.insert(t.tool_id.clone(), t.clone());
397    }
398
399    // Count tool invocations from events
400    for e in events {
401        if let super::event::EventType::AgentCalledTool { ref tool_name, .. } = e.event_type {
402            let entry = tools.entry(tool_name.clone()).or_insert_with(|| ToolInfo {
403                tool_id: tool_name.clone(),
404                tool_name: tool_name.clone(),
405                tool_runtime_id: e.tool_runtime_id.clone(),
406                invocation_count: 0,
407            });
408            entry.invocation_count += 1;
409        }
410    }
411
412    tools.into_values().collect()
413}
414
415fn build_merkle(artifacts: &[ArtifactEntry]) -> (MerkleSection, Option<MerkleTree>) {
416    if artifacts.is_empty() {
417        return (MerkleSection::default(), None);
418    }
419
420    let mut tree = MerkleTree::new();
421    for art in artifacts {
422        tree.append(&art.artifact_id);
423    }
424
425    let root = tree.root().map(|r| format!("mroot_{}", hex::encode(r)));
426
427    // Build inclusion proofs for each artifact
428    let inclusion_proofs: Vec<InclusionProofEntry> = artifacts.iter().enumerate()
429        .filter_map(|(i, art)| {
430            tree.inclusion_proof(i).map(|proof| InclusionProofEntry {
431                artifact_id: art.artifact_id.clone(),
432                leaf_index: i,
433                proof,
434            })
435        })
436        .collect();
437
438    let section = MerkleSection {
439        leaf_count: artifacts.len(),
440        root,
441        checkpoint_id: None,
442        inclusion_proofs,
443    };
444
445    (section, Some(tree))
446}
447
448/// Extract the ship_id from an actor URI of the form `ship://<id>`.
449/// Returns None for other URI schemes (human://, agent://) or malformed values.
450pub fn parse_ship_id_from_actor(actor: &str) -> Option<String> {
451    let rest = actor.strip_prefix("ship://")?;
452    // Strip any trailing path segment so `ship://ship_abc/foo` -> `ship_abc`.
453    let id = rest.split('/').next().unwrap_or(rest);
454    if id.is_empty() { None } else { Some(id.to_string()) }
455}
456
457/// Extract a human-readable label from an EventType.
458/// Derive tool usage from side effects and the declared authorized tools list.
459fn derive_tool_usage(
460    side_effects: &SideEffects,
461    authorized_tools: &[String],
462) -> Option<ToolUsage> {
463    use std::collections::BTreeMap;
464
465    if side_effects.tool_invocations.is_empty() && authorized_tools.is_empty() {
466        return None;
467    }
468
469    // Count actual tool usage
470    let mut counts: BTreeMap<String, u32> = BTreeMap::new();
471    for inv in &side_effects.tool_invocations {
472        *counts.entry(inv.tool_name.clone()).or_insert(0) += 1;
473    }
474
475    let actual: Vec<ToolUsageEntry> = counts.iter()
476        .map(|(name, &count)| ToolUsageEntry { tool_name: name.clone(), count })
477        .collect();
478
479    // Find tools called that were NOT in the declared list
480    let unauthorized = if authorized_tools.is_empty() {
481        Vec::new()
482    } else {
483        let declared_set: std::collections::BTreeSet<&str> = authorized_tools.iter()
484            .map(|s| s.as_str())
485            .collect();
486        counts.keys()
487            .filter(|name| !declared_set.contains(name.as_str()))
488            .cloned()
489            .collect()
490    };
491
492    Some(ToolUsage {
493        declared: authorized_tools.to_vec(),
494        actual,
495        unauthorized,
496    })
497}
498
499fn event_type_label(et: &super::event::EventType) -> String {
500    use super::event::EventType::*;
501    match et {
502        SessionStarted => "session.started",
503        SessionClosed { .. } => "session.closed",
504        AgentStarted { .. } => "agent.started",
505        AgentSpawned { .. } => "agent.spawned",
506        AgentHandoff { .. } => "agent.handoff",
507        AgentCollaborated { .. } => "agent.collaborated",
508        AgentReturned { .. } => "agent.returned",
509        AgentCompleted { .. } => "agent.completed",
510        AgentFailed { .. } => "agent.failed",
511        AgentCalledTool { .. } => "agent.called_tool",
512        AgentReadFile { .. } => "agent.read_file",
513        AgentWroteFile { .. } => "agent.wrote_file",
514        AgentOpenedPort { .. } => "agent.opened_port",
515        AgentConnectedNetwork { .. } => "agent.connected_network",
516        AgentStartedProcess { .. } => "agent.started_process",
517        AgentCompletedProcess { .. } => "agent.completed_process",
518        AgentDecision { .. } => "agent.decision",
519    }.into()
520}
521
522/// Optional human-readable summary from an EventType.
523fn event_summary(et: &super::event::EventType) -> Option<String> {
524    use super::event::EventType::*;
525    match et {
526        SessionStarted => Some("Session started".into()),
527        SessionClosed { summary, .. } => summary.clone().or(Some("Session closed".into())),
528        AgentSpawned { reason, .. } => reason.clone(),
529        AgentHandoff { from_agent_instance_id, to_agent_instance_id, .. } => {
530            Some(format!("{from_agent_instance_id} -> {to_agent_instance_id}"))
531        }
532        AgentCalledTool { tool_name, .. } => Some(format!("Called {tool_name}")),
533        AgentReadFile { file_path, .. } => Some(format!("Read {file_path}")),
534        AgentWroteFile { file_path, .. } => Some(format!("Wrote {file_path}")),
535        AgentOpenedPort { port, .. } => Some(format!("Opened port {port}")),
536        AgentConnectedNetwork { destination, .. } => Some(format!("Connected to {destination}")),
537        AgentStartedProcess { process_name, .. } => Some(format!("Started {process_name}")),
538        AgentCompletedProcess { process_name, exit_code, .. } => {
539            Some(format!("Completed {process_name} (exit {})", exit_code.unwrap_or(-1)))
540        }
541        AgentCompleted { termination_reason } => termination_reason.clone().or(Some("Agent completed".into())),
542        AgentFailed { reason } => reason.clone().or(Some("Agent failed".into())),
543        AgentDecision { model, summary, provider, .. } => {
544            let mut parts = Vec::new();
545            if let Some(s) = summary { parts.push(s.clone()); }
546            if let Some(m) = model { parts.push(format!("model: {m}")); }
547            if let Some(p) = provider { parts.push(format!("via {p}")); }
548            if parts.is_empty() { Some("LLM decision".into()) } else { Some(parts.join(" | ")) }
549        }
550        _ => None,
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use crate::session::event::*;
558
559    fn make_manifest() -> SessionManifest {
560        SessionManifest::new(
561            "ssn_001".into(),
562            "agent://test".into(),
563            "2026-04-05T08:00:00Z".into(),
564            1743843600000,
565        )
566    }
567
568    fn make_events() -> Vec<SessionEvent> {
569        let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
570            SessionEvent {
571                session_id: "ssn_001".into(),
572                event_id: format!("evt_{:016x}", seq),
573                timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
574                sequence_no: seq,
575                trace_id: "trace_1".into(),
576                span_id: format!("span_{seq}"),
577                parent_span_id: None,
578                agent_id: format!("agent://{inst}"),
579                agent_instance_id: inst.into(),
580                agent_name: inst.into(),
581                agent_role: None,
582                host_id: "host_1".into(),
583                tool_runtime_id: None,
584                event_type: et,
585                artifact_ref: None,
586                meta: None,
587            }
588        };
589
590        vec![
591            mk(0, "root", EventType::SessionStarted),
592            mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
593            mk(2, "worker", EventType::AgentSpawned { spawned_by_agent_instance_id: "root".into(), reason: Some("review".into()) }),
594            mk(3, "worker", EventType::AgentCalledTool { tool_name: "read_file".into(), tool_input_digest: None, tool_output_digest: None, duration_ms: Some(5) }),
595            mk(4, "worker", EventType::AgentWroteFile { file_path: "src/fix.rs".into(), digest: None, operation: None, additions: None, deletions: None }),
596            mk(5, "worker", EventType::AgentCompleted { termination_reason: None }),
597            mk(6, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(360000) }),
598        ]
599    }
600
601    #[test]
602    fn compose_receipt() {
603        let manifest = make_manifest();
604        let events = make_events();
605        let artifacts = vec![
606            ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
607            ArtifactEntry { artifact_id: "art_002".into(), payload_type: "action".into(), digest: None, signed_at: None },
608        ];
609
610        let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
611
612        assert_eq!(receipt.type_, RECEIPT_TYPE);
613        assert_eq!(receipt.session.id, "ssn_001");
614        assert_eq!(receipt.timeline.len(), 7);
615        assert_eq!(receipt.agent_graph.nodes.len(), 2); // root + worker
616        assert_eq!(receipt.side_effects.files_written.len(), 1);
617        assert_eq!(receipt.merkle.leaf_count, 2);
618        assert!(receipt.merkle.root.is_some());
619    }
620
621    #[test]
622    fn new_receipts_carry_schema_version() {
623        let manifest = make_manifest();
624        let events = make_events();
625        let artifacts = vec![
626            ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
627        ];
628        let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
629        assert_eq!(receipt.schema_version.as_deref(), Some(RECEIPT_SCHEMA_VERSION));
630        // And it shows up in canonical JSON.
631        let json = String::from_utf8(ReceiptComposer::to_canonical_json(&receipt).unwrap()).unwrap();
632        assert!(json.contains(r#""schema_version":"1""#), "missing schema_version: {json}");
633    }
634
635    #[test]
636    fn legacy_receipt_without_schema_version_round_trips_byte_identical() {
637        // Simulate a pre-v0.9.0 receipt by composing one and stripping the
638        // schema_version field. Re-serializing must produce byte-identical
639        // output so the package-level determinism check keeps passing for
640        // old receipts that nobody can re-sign.
641        let manifest = make_manifest();
642        let events = make_events();
643        let artifacts = vec![
644            ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
645        ];
646        let mut receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
647        receipt.schema_version = None; // mimic a legacy receipt
648
649        let original = ReceiptComposer::to_canonical_json(&receipt).unwrap();
650        // Verify the field is omitted, not serialized as null.
651        let original_str = std::str::from_utf8(&original).unwrap();
652        assert!(!original_str.contains("schema_version"),
653            "schema_version must be skipped when None");
654
655        let parsed: SessionReceipt = serde_json::from_slice(&original).unwrap();
656        assert!(parsed.schema_version.is_none(), "legacy receipts must parse with schema_version=None");
657
658        let reserialized = ReceiptComposer::to_canonical_json(&parsed).unwrap();
659        assert_eq!(original, reserialized,
660            "legacy receipt must round-trip byte-identical so package determinism check passes");
661    }
662
663    #[test]
664    fn canonical_json_is_deterministic() {
665        let manifest = make_manifest();
666        let events = make_events();
667        let artifacts = vec![
668            ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
669        ];
670
671        let r1 = ReceiptComposer::compose(&manifest, &events, artifacts.clone());
672        let r2 = ReceiptComposer::compose(&manifest, &events, artifacts);
673
674        let j1 = ReceiptComposer::to_canonical_json(&r1).unwrap();
675        let j2 = ReceiptComposer::to_canonical_json(&r2).unwrap();
676        assert_eq!(j1, j2);
677
678        let d1 = ReceiptComposer::digest(&r1).unwrap();
679        let d2 = ReceiptComposer::digest(&r2).unwrap();
680        assert_eq!(d1, d2);
681    }
682}