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::{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
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    /// What each action/v2 in this session was authorized to do, and whether it
57    /// stayed inside that.
58    ///
59    /// Absent when the session contained no action/v2 receipts, which keeps
60    /// older receipts byte-identical. Present-but-empty never happens: a
61    /// session with nothing to say about authority says nothing, rather than
62    /// showing an empty band that reads like a clean bill.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub authority: Option<AuthoritySection>,
65
66    /// Who actually held the signing key, when that is not the actor.
67    ///
68    /// Absent means self-custody -- the actor signed for itself, which is the
69    /// default and the strong case. Present means a service signed on the
70    /// actor's behalf, which is a materially weaker claim and has to be
71    /// legible as such rather than inferred from context.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub custody: Option<Custody>,
74}
75
76/// Who signed, when that is not the actor itself.
77///
78/// This is a **separate axis from `attestation_class`**, and keeping them
79/// separate is the point. `attestation_class` grades how evidence was
80/// *captured* (self / runtime / countersigned). Custody grades who held the
81/// *key*. They vary independently: a service-mediated room can have excellent
82/// runtime-captured evidence and still be custodially signed, and an agent
83/// signing for itself can have nothing but its own word.
84///
85/// Collapsing them is the same error `EffectConfidence` and `EffectFinality`
86/// exist to avoid -- one label carrying two unrelated questions, where a
87/// reader cannot tell which one a value is answering.
88///
89/// The distinction is not cosmetic. Under self-custody, forging a
90/// participant's action requires that participant's key. Under delegated
91/// custody, a compromised service can mint any history it likes for every
92/// actor it signs for. Same receipt shape, different threat model, so the
93/// receipt says which.
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95pub struct Custody {
96    /// Custody mode. Only `delegated` is ever serialized -- self-custody is
97    /// represented by the whole section being absent, so existing receipts
98    /// stay byte-identical and "no custody block" cannot be misread as
99    /// "custody unknown".
100    pub mode: CustodyMode,
101
102    /// The identity whose key actually produced the signature, e.g.
103    /// `svc://gateway-rooms`. This is who a verifier is really trusting.
104    pub signer: String,
105
106    /// The actor the signature is claimed to be *for*, e.g. `agent://fizz`.
107    /// A verifier can confirm `signer` signed; it cannot confirm this actor
108    /// agreed, and must not present it as though it could.
109    pub on_behalf_of: String,
110
111    /// Optional human-readable reason the actor did not sign for itself
112    /// (e.g. "browser-mediated room; participants hold no local key").
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub reason: Option<String>,
115}
116
117/// How the signature relates to the actor it speaks for.
118#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
119#[serde(rename_all = "snake_case")]
120pub enum CustodyMode {
121    /// A service signed on the actor's behalf. The actor may hold no key at
122    /// all. Upgrade path: the actor registers its own key and joins via
123    /// `session invite` / `join` / `countersign`, which produces a
124    /// two-signature participant event no service can forge.
125    Delegated,
126}
127
128impl Custody {
129    /// A service signing for an actor that holds no key of its own.
130    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    /// Attach the reason the actor did not sign for itself.
140    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
141        self.reason = Some(reason.into());
142        self
143    }
144}
145
146/// Per-action authority for a session.
147///
148/// The signature layer answers "was this receipt tampered with". This answers
149/// the question underneath it: was the action allowed, by whom, and what could
150/// we not check. A session receipt that reports only the former reads as
151/// complete while omitting the half a counterparty is actually deciding on.
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153pub struct AuthoritySection {
154    pub actions: Vec<AuthorityEntry>,
155    /// How many action/v2 receipts were judged.
156    pub checked: u32,
157    /// Actions that fell outside their grant. Any non-zero value is the
158    /// headline.
159    pub violations: u32,
160    /// Actions where some layer could not be checked. Not violations, and not
161    /// clean either -- counted separately so neither can hide in the other.
162    pub unverified: u32,
163    /// Actions run under a grant naming no holder, spendable by anyone who
164    /// obtained it.
165    pub bearer: u32,
166}
167
168/// One action's authority record.
169#[derive(Debug, Clone, Default, Serialize, Deserialize)]
170pub struct AuthorityEntry {
171    pub artifact_id: String,
172    /// The action label, e.g. `payments.charge`.
173    pub action: String,
174    /// `pass` | `unverified` | `fail`.
175    pub verdict: String,
176    /// Why, in the verifier's own words. Empty on a clean pass.
177    #[serde(default, skip_serializing_if = "Vec::is_empty")]
178    pub reasons: Vec<String>,
179    /// What the grant admitted.
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    pub scope: Vec<String>,
182    pub audience: String,
183    pub grant_id: String,
184    /// Whether the grant named the key entitled to exercise it. `false` means
185    /// bearer, and the surface must say so rather than leave it blank.
186    pub holder_bound: bool,
187    /// `not_claimed` | `holds` | `widened` | `unresolvable`.
188    pub delegation: String,
189    /// Hops in the resolved chain, when one was claimed.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub delegation_hops: Option<u32>,
192    /// How far the state change got: `not_attempted` | `initiated` |
193    /// `finalized` | `failed` | `indeterminate`.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub effect_finality: Option<String>,
196    /// Whether anything is still owed: `resolved` | `indefinite` | `pending` |
197    /// `breached` | `bad_deadline`.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub resolution: Option<String>,
200}
201
202/// Tool authorization and usage summary for the session.
203#[derive(Debug, Clone, Default, Serialize, Deserialize)]
204pub struct ToolUsage {
205    /// Tools declared as authorized (from declaration.json).
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub declared: Vec<String>,
208    /// Tools actually called during the session with invocation counts.
209    #[serde(default, skip_serializing_if = "Vec::is_empty")]
210    pub actual: Vec<ToolUsageEntry>,
211    /// Tools called that were NOT in the declared list.
212    #[serde(default, skip_serializing_if = "Vec::is_empty")]
213    pub unauthorized: Vec<String>,
214    /// Network destinations declared for the session (from declaration.json
215    /// `network`): exact hosts or `*.suffix` patterns. Absent when none was
216    /// declared.
217    #[serde(default, skip_serializing_if = "Vec::is_empty")]
218    pub network_declared: Vec<String>,
219    /// Destinations the session connected to that match none of the declared
220    /// patterns. Only computed when a scope was declared; absent otherwise.
221    #[serde(default, skip_serializing_if = "Vec::is_empty")]
222    pub network_off_scope: Vec<String>,
223}
224
225/// Does `host` fall inside a declared network scope? A pattern is an exact
226/// host (`api.example.com`), a suffix wildcard (`*.example.com`, which also
227/// matches `example.com` itself), or `*` for any host. Case-insensitive; a
228/// trailing dot on the host is ignored. Shared by the receipt composer and
229/// the package verifier so both judge the same way.
230pub fn host_in_scope(host: &str, scope: &[String]) -> bool {
231    let h = host.trim().trim_end_matches('.').to_ascii_lowercase();
232    if h.is_empty() {
233        return false;
234    }
235    scope.iter().any(|pat| {
236        let p = pat.trim().to_ascii_lowercase();
237        if p == "*" {
238            true
239        } else if let Some(suffix) = p.strip_prefix("*.") {
240            h == suffix || h.ends_with(&format!(".{suffix}"))
241        } else {
242            h == p
243        }
244    })
245}
246
247/// A single tool's usage count.
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct ToolUsageEntry {
250    pub tool_name: String,
251    pub count: u32,
252}
253
254/// Session metadata section of the receipt.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct SessionSection {
257    pub id: String,
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub name: Option<String>,
260    pub mode: LifecycleMode,
261    pub started_at: String,
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub ended_at: Option<String>,
264    pub status: SessionStatus,
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub duration_ms: Option<u64>,
267    /// Ship ID this session ran under, parsed from the manifest actor URI
268    /// (`ship://<ship_id>`). Absent on pre-v0.9.0 receipts or when the actor
269    /// URI was not a ship:// URI (e.g. human://alice for a human-led session).
270    /// Cross-verification uses this to check that a receipt and a presented
271    /// Agent Certificate reference the same ship.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub ship_id: Option<String>,
274    /// Workflow declaration bound into the signed session-start root action.
275    /// This copy makes the binding visible on the composed session receipt;
276    /// verifiers must compare it with the root action rather than trusting an
277    /// unsigned manifest value.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub workflow_ref: Option<String>,
280    /// Structured narrative for human review. All fields optional.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub narrative: Option<Narrative>,
283    /// Cumulative input tokens across all agents.
284    #[serde(default)]
285    pub total_tokens_in: u64,
286    /// Cumulative output tokens across all agents.
287    #[serde(default)]
288    pub total_tokens_out: u64,
289    /// Room this session hosted, mirrored from the manifest. Carried here so
290    /// `invitation_authority` sits inside the DSSE-signed payload instead of
291    /// only in unsigned `session.json` -- see `RoomInfo`'s doc comment for
292    /// why an unsigned authority field is a wire-controllable-dispatch-field
293    /// risk. Absent for ordinary (non-room) sessions.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub room: Option<RoomInfo>,
296}
297
298/// Structured narrative for the session summary.
299#[derive(Debug, Clone, Default, Serialize, Deserialize)]
300pub struct Narrative {
301    /// One-line headline: "Verifier refactor completed."
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub headline: Option<String>,
304    /// Multi-sentence summary of what happened.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub summary: Option<String>,
307    /// What should be reviewed before trusting the output.
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub review: Option<String>,
310}
311
312/// A single timeline entry.
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct TimelineEntry {
315    pub sequence_no: u64,
316    pub timestamp: String,
317    pub event_id: String,
318    pub event_type: String,
319    pub agent_instance_id: String,
320    pub agent_name: String,
321    pub host_id: String,
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub summary: Option<String>,
324}
325
326/// An artifact referenced in the session.
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct ArtifactEntry {
329    pub artifact_id: String,
330    pub payload_type: String,
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub digest: Option<String>,
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub signed_at: Option<String>,
335    /// Signed during the session but never chained onto it (no `--parent`,
336    /// or a parent off the sealed path). Sealed and signed like every other
337    /// entry; its position relative to the chain is the signer's claim only.
338    /// Absent (false) for chained entries and for every pre-0.31.2 receipt.
339    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
340    pub unchained: bool,
341}
342
343/// Proofs section of the receipt.
344#[derive(Debug, Clone, Default, Serialize, Deserialize)]
345pub struct ProofsSection {
346    #[serde(default)]
347    pub signature_count: u32,
348    #[serde(default)]
349    pub signatures_valid: bool,
350    #[serde(default)]
351    pub merkle_root_valid: bool,
352    #[serde(default)]
353    pub inclusion_proofs_count: u32,
354    #[serde(default)]
355    pub zk_proofs_present: bool,
356    /// Count of events.jsonl lines that were skipped during read_all
357    /// because they failed to deserialize. Set by session::close from
358    /// EventLog::read_all_with_stats. Codex adversarial review finding #8:
359    /// without this in-band signal, a receipt sealed after malformed
360    /// events were silently dropped looks complete to a verifier even
361    /// when it isn't. `treeship package verify` surfaces this as a WARN
362    /// when nonzero. Defaults to 0; absent on pre-v0.9.6 receipts so
363    /// they still verify byte-identical.
364    #[serde(default, skip_serializing_if = "is_zero_u32")]
365    pub event_log_skipped: u32,
366    #[serde(default, skip_serializing_if = "is_zero_u32")]
367    pub reconcile_untracked_truncated: u32,
368    #[serde(default, skip_serializing_if = "is_zero_u32")]
369    pub reconcile_untracked_cap: u32,
370    /// AUD-07: the git-diff backstop was unavailable at close even though git
371    /// worked at session start (start_commit_sha was captured). A file could
372    /// have changed via a non-AgentWroteFile channel and the only backstop
373    /// that would have caught it was disabled (`.git` removed, corrupt index,
374    /// PATH-poisoned git), so the "Files changed" ledger may be incomplete.
375    /// `package verify` WARNs on this. Absent on receipts sealed before this
376    /// field so they stay byte-identical.
377    #[serde(default, skip_serializing_if = "is_false")]
378    pub reconcile_degraded: bool,
379}
380
381fn is_zero_u32(n: &u32) -> bool {
382    *n == 0
383}
384fn is_false(b: &bool) -> bool {
385    !*b
386}
387
388/// Merkle section of the receipt.
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct MerkleSection {
391    pub leaf_count: usize,
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub root: Option<String>,
394    #[serde(skip_serializing_if = "Option::is_none")]
395    pub checkpoint_id: Option<String>,
396    #[serde(default, skip_serializing_if = "Vec::is_empty")]
397    pub inclusion_proofs: Vec<InclusionProofEntry>,
398    /// Merkle format version byte. Drives the leaf/internal hash dispatch
399    /// at verify time. Absent on pre-v0.10.3 receipts — defaults to `1`
400    /// (no domain separation) so v0.10.2 receipts continue to verify.
401    /// New receipts always serialize `2` (RFC 9162 domain separation).
402    #[serde(default = "crate::merkle::tree::default_merkle_version_v1")]
403    pub merkle_version: u8,
404}
405
406impl Default for MerkleSection {
407    fn default() -> Self {
408        // Default newly-constructed sections to v2 — the in-the-wild
409        // "default = v1" behavior only triggers when serde fills the
410        // field for a JSON that omitted it (legacy receipts).
411        Self {
412            leaf_count: 0,
413            root: None,
414            checkpoint_id: None,
415            inclusion_proofs: Vec::new(),
416            merkle_version: crate::merkle::tree::MERKLE_VERSION_V2,
417        }
418    }
419}
420
421/// A Merkle inclusion proof entry.
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct InclusionProofEntry {
424    pub artifact_id: String,
425    pub leaf_index: usize,
426    pub proof: InclusionProof,
427}
428
429// ── Composer ─────────────────────────────────────────────────────────
430
431/// Composes a Session Receipt from events and artifacts.
432pub struct ReceiptComposer;
433
434impl ReceiptComposer {
435    /// Compose a receipt from a session manifest, events, and optional artifact entries.
436    pub fn compose(
437        manifest: &SessionManifest,
438        events: &[SessionEvent],
439        artifact_entries: Vec<ArtifactEntry>,
440    ) -> SessionReceipt {
441        Self::compose_with_custody(manifest, events, artifact_entries, None)
442    }
443
444    /// Compose a receipt for a session whose actor did NOT hold the signing
445    /// key -- a service signing on its behalf.
446    ///
447    /// Use this from any surface that mediates for actors who hold no key of
448    /// their own (a browser-based room being the motivating case). Passing
449    /// `None` is identical to [`compose`]: self-custody is the absence of the
450    /// block, so nothing is added to the signed bytes and existing receipts
451    /// stay byte-identical.
452    ///
453    /// Recording it is not optional politeness. A receipt naming
454    /// `agent://fizz` when a service actually signed is a lie of omission, and
455    /// it is the kind that surfaces in someone else's security review rather
456    /// than ours.
457    pub fn compose_with_custody(
458        manifest: &SessionManifest,
459        events: &[SessionEvent],
460        artifact_entries: Vec<ArtifactEntry>,
461        custody: Option<Custody>,
462    ) -> SessionReceipt {
463        // Build agent graph
464        let agent_graph = AgentGraph::from_events(events);
465
466        // Build side effects
467        let side_effects = SideEffects::from_events(events);
468
469        // Build timeline from all events
470        let mut timeline: Vec<TimelineEntry> = events
471            .iter()
472            .map(|e| TimelineEntry {
473                sequence_no: e.sequence_no,
474                timestamp: e.timestamp.clone(),
475                event_id: e.event_id.clone(),
476                event_type: event_type_label(&e.event_type),
477                agent_instance_id: e.agent_instance_id.clone(),
478                agent_name: e.agent_name.clone(),
479                host_id: e.host_id.clone(),
480                summary: event_summary(&e.event_type),
481            })
482            .collect();
483
484        // Sort by (timestamp, sequence_no, event_id) for determinism
485        timeline.sort_by(|a, b| {
486            a.timestamp
487                .cmp(&b.timestamp)
488                .then(a.sequence_no.cmp(&b.sequence_no))
489                .then(a.event_id.cmp(&b.event_id))
490        });
491
492        // Compute participants from graph
493        let participants = compute_participants(&agent_graph, manifest);
494
495        // Compute hosts and tools from events
496        let hosts = compute_hosts(events, &manifest.hosts);
497        let tools = compute_tools(events, &manifest.tools);
498
499        // Compute duration from the session close event if present
500        let duration_ms = events.iter().find_map(|e| {
501            if let super::event::EventType::SessionClosed { duration_ms, .. } = &e.event_type {
502                *duration_ms
503            } else {
504                None
505            }
506        });
507
508        // Build Merkle tree from artifact IDs
509        let (merkle_section, merkle_tree) = build_merkle(&artifact_entries);
510
511        // Proofs section. zk_proofs_present defaults to false here;
512        // the CLI caller sets it to true after compose if proof files
513        // exist in the session directory.
514        let proofs = ProofsSection {
515            signature_count: artifact_entries.len() as u32,
516            // AUD-01: compose does NOT run a signature-verification pass over
517            // the artifacts, so this must not claim signatures were verified.
518            // A `true` here was a self-asserted "valid" flag baked into the
519            // signed receipt that a consumer could mistake for an independent
520            // verification result. It stays false unless a real verify pass
521            // sets it.
522            signatures_valid: false,
523            merkle_root_valid: merkle_tree.is_some(),
524            inclusion_proofs_count: merkle_section.inclusion_proofs.len() as u32,
525            zk_proofs_present: false,
526            event_log_skipped: 0, // Set by caller after compose (Codex #8)
527            reconcile_untracked_truncated: 0,
528            reconcile_untracked_cap: 0,
529            reconcile_degraded: false, // Set by caller after compose (AUD-07)
530        };
531
532        // Compute cost/token totals from agent graph
533        // Cost is deliberately not aggregated. See event.rs comment.
534        let total_tokens_in: u64 = agent_graph.nodes.iter().map(|n| n.tokens_in).sum();
535        let total_tokens_out: u64 = agent_graph.nodes.iter().map(|n| n.tokens_out).sum();
536
537        // Session section
538        let session = SessionSection {
539            id: manifest.session_id.clone(),
540            name: manifest.name.clone(),
541            mode: manifest.mode.clone(),
542            started_at: manifest.started_at.clone(),
543            ended_at: manifest.closed_at.clone(),
544            status: manifest.status.clone(),
545            duration_ms,
546            ship_id: parse_ship_id_from_actor(&manifest.actor),
547            workflow_ref: manifest.workflow_ref.clone(),
548            narrative: manifest.summary.as_ref().map(|s| Narrative {
549                headline: manifest.name.clone(),
550                summary: Some(s.clone()),
551                review: None,
552            }),
553            total_tokens_in,
554            total_tokens_out,
555            room: manifest.room.clone(),
556        };
557
558        // Render config
559        let render = RenderConfig {
560            title: manifest.name.clone(),
561            theme: None,
562            sections: RenderConfig::default_sections(),
563            generate_preview: true,
564        };
565
566        // Derive tool usage from side effects + manifest authorized_tools
567        let tool_usage = derive_tool_usage(
568            &side_effects,
569            &manifest.authorized_tools,
570            &manifest.network_scope,
571        );
572
573        SessionReceipt {
574            type_: RECEIPT_TYPE.into(),
575            schema_version: Some(RECEIPT_SCHEMA_VERSION.into()),
576            session,
577            participants,
578            hosts,
579            tools,
580            agent_graph,
581            timeline,
582            side_effects,
583            artifacts: artifact_entries,
584            proofs,
585            merkle: merkle_section,
586            render,
587            tool_usage,
588            // Composed from storage by the caller, which is the layer that can
589            // load envelopes and run the verifier. The composer sees only
590            // manifest + events + artifact metadata.
591            authority: None,
592            custody,
593        }
594    }
595
596    /// Produce deterministic canonical JSON bytes from a receipt.
597    ///
598    /// Uses serde's field-declaration-order serialization for determinism.
599    /// The resulting bytes are suitable for hashing.
600    pub fn to_canonical_json(receipt: &SessionReceipt) -> Result<Vec<u8>, serde_json::Error> {
601        serde_json::to_vec(receipt)
602    }
603
604    /// Compute SHA-256 digest of the canonical receipt JSON.
605    pub fn digest(receipt: &SessionReceipt) -> Result<String, serde_json::Error> {
606        let bytes = Self::to_canonical_json(receipt)?;
607        let hash = Sha256::digest(&bytes);
608        Ok(format!("sha256:{}", hex::encode(hash)))
609    }
610}
611
612// ── Helpers ──────────────────────────────────────────────────────────
613
614fn compute_participants(graph: &AgentGraph, manifest: &SessionManifest) -> Participants {
615    use std::collections::BTreeSet;
616
617    let mut tool_runtimes: BTreeSet<String> = BTreeSet::new();
618    // Count unique agents
619    let total_agents = graph.nodes.len() as u32;
620    let spawned_subagents = graph.spawn_count();
621    let handoffs = graph.handoff_count();
622    let max_depth = graph.max_depth();
623    let host_ids = graph.host_ids();
624
625    // Collect tool runtimes from events in manifest
626    for tool in &manifest.tools {
627        if let Some(ref rt) = tool.tool_runtime_id {
628            tool_runtimes.insert(rt.clone());
629        }
630    }
631
632    // Find root agent (depth 0, first started)
633    let root = graph
634        .nodes
635        .iter()
636        .filter(|n| n.depth == 0)
637        .min_by_key(|n| n.started_at.as_deref().unwrap_or(""))
638        .map(|n| n.agent_instance_id.clone());
639
640    // Find final output agent (last completed at max depth or last completed overall)
641    let final_output = graph
642        .nodes
643        .iter()
644        .filter(|n| n.completed_at.is_some())
645        .max_by_key(|n| n.completed_at.as_deref().unwrap_or(""))
646        .map(|n| n.agent_instance_id.clone());
647
648    Participants {
649        root_agent_instance_id: root.or(manifest.participants.root_agent_instance_id.clone()),
650        final_output_agent_instance_id: final_output
651            .or(manifest.participants.final_output_agent_instance_id.clone()),
652        total_agents,
653        spawned_subagents,
654        handoffs,
655        max_depth,
656        hosts: host_ids.len() as u32,
657        tool_runtimes: tool_runtimes.len() as u32,
658    }
659}
660
661fn compute_hosts(events: &[SessionEvent], manifest_hosts: &[HostInfo]) -> Vec<HostInfo> {
662    use std::collections::BTreeMap;
663
664    let mut hosts: BTreeMap<String, HostInfo> = BTreeMap::new();
665
666    // Seed from manifest
667    for h in manifest_hosts {
668        hosts.insert(h.host_id.clone(), h.clone());
669    }
670
671    // Discover from events
672    for e in events {
673        hosts.entry(e.host_id.clone()).or_insert_with(|| HostInfo {
674            host_id: e.host_id.clone(),
675            hostname: None,
676            os: None,
677            arch: None,
678        });
679    }
680
681    hosts.into_values().collect()
682}
683
684fn compute_tools(events: &[SessionEvent], manifest_tools: &[ToolInfo]) -> Vec<ToolInfo> {
685    use std::collections::BTreeMap;
686
687    let mut tools: BTreeMap<String, ToolInfo> = BTreeMap::new();
688
689    // Seed from manifest
690    for t in manifest_tools {
691        tools.insert(t.tool_id.clone(), t.clone());
692    }
693
694    // Count tool invocations from events
695    for e in events {
696        if let super::event::EventType::AgentCalledTool { ref tool_name, .. } = e.event_type {
697            let entry = tools.entry(tool_name.clone()).or_insert_with(|| ToolInfo {
698                tool_id: tool_name.clone(),
699                tool_name: tool_name.clone(),
700                tool_runtime_id: e.tool_runtime_id.clone(),
701                invocation_count: 0,
702            });
703            entry.invocation_count += 1;
704        }
705    }
706
707    tools.into_values().collect()
708}
709
710fn build_merkle(artifacts: &[ArtifactEntry]) -> (MerkleSection, Option<MerkleTree>) {
711    if artifacts.is_empty() {
712        return (MerkleSection::default(), None);
713    }
714
715    let mut tree = MerkleTree::new();
716    for art in artifacts {
717        tree.append(&art.artifact_id);
718    }
719
720    let root = tree.root().map(|r| format!("mroot_{}", hex::encode(r)));
721
722    // Build inclusion proofs for each artifact
723    let inclusion_proofs: Vec<InclusionProofEntry> = artifacts
724        .iter()
725        .enumerate()
726        .filter_map(|(i, art)| {
727            tree.inclusion_proof(i).map(|proof| InclusionProofEntry {
728                artifact_id: art.artifact_id.clone(),
729                leaf_index: i,
730                proof,
731            })
732        })
733        .collect();
734
735    let section = MerkleSection {
736        leaf_count: artifacts.len(),
737        root,
738        checkpoint_id: None,
739        inclusion_proofs,
740        merkle_version: tree.version(),
741    };
742
743    (section, Some(tree))
744}
745
746/// Extract the ship_id from an actor URI of the form `ship://<id>`.
747/// Returns None for other URI schemes (human://, agent://) or malformed values.
748pub fn parse_ship_id_from_actor(actor: &str) -> Option<String> {
749    let rest = actor.strip_prefix("ship://")?;
750    // Strip any trailing path segment so `ship://ship_abc/foo` -> `ship_abc`.
751    let id = rest.split('/').next().unwrap_or(rest);
752    if id.is_empty() {
753        None
754    } else {
755        Some(id.to_string())
756    }
757}
758
759/// Extract a human-readable label from an EventType.
760/// Derive tool usage from side effects and the declared authorized tools list.
761///
762/// Bug Codex caught in adversarial review: previously this function counted
763/// only `side_effects.tool_invocations` (built from `EventType::AgentCalledTool`).
764/// But Claude Code's PostToolUse hook emits SPECIALIZED events for built-in
765/// tools (`agent.wrote_file` for Write/Edit, `agent.completed_process` for
766/// Bash, `agent.read_file` for Read, etc) -- those events never landed in
767/// `tool_invocations`, so a certificate that omitted "Bash" or "Write"
768/// passed cross-verification cleanly even when the agent ran them.
769///
770/// The fix: also count side effects from specialized event types under
771/// canonical tool names that match what an operator would declare in
772/// `bounded_actions`. Naming follows Claude Code conventions (Read, Write,
773/// Bash, WebFetch) since those are the tools users actually declare. A
774/// cert that uses an alternate naming scheme (e.g. `files.write`) needs
775/// to declare both for now -- a future TODO is canonical mapping at the
776/// cert layer.
777/// Side-effect canonical mapping for tool authorization.
778///
779/// Each entry maps a side-effect bucket to a canonical tool name AND a
780/// list of accepted aliases. The canonical name is what gets recorded
781/// in `tool_usage.actual`. Any alias from the authorized_tools list
782/// counts as authorization for the canonical name.
783///
784/// Codex round-2 caught two bugs in the round-1 fix:
785///
786/// 1. The round-1 mapping used Claude-Code TitleCase ("Read", "Write",
787///    "Bash") but the existing CLI -- `treeship declare --tools
788///    read_file,write_file,bash` per declare.rs:80 and `treeship agent
789///    register --tools read_file,write_file,bash` per main.rs:226 --
790///    teaches users lowercase snake_case names. So a cert that follows
791///    the documented convention got every actual tool flagged as
792///    unauthorized. Aliases close that gap: declarations in either
793///    convention authorize the same canonical entry.
794///
795/// 2. The round-1 logic counted side effects regardless of provenance.
796///    `git-reconcile` synthetic writes (the backstop layer) registered
797///    as tool use even though no actual tool was directly attributed
798///    for them. A build script that touched a file made the receipt
799///    say "Write tool was used", and the cert had to authorize Write
800///    or fail cross-verify -- even though the agent never invoked any
801///    Write tool. Below, only direct-attribution sources (`hook`,
802///    `mcp`, `shell-wrap`, `session-event-cli`, and untagged legacy
803///    events) count toward tool usage. Backstop sources (`git-reconcile`,
804///    `daemon-atime`) surface in the receipt's "Files changed" section
805///    so the reader sees the change, but they do NOT claim that an
806///    agent tool was the proximate cause. See source_attributes_a_tool
807///    below for the authoritative allow list.
808const TOOL_ALIASES: &[(&str, &[&str])] = &[
809    // Canonical first; rest are accepted aliases.
810    ("read_file", &["read_file", "Read"]),
811    (
812        "write_file",
813        &[
814            "write_file",
815            "Write",
816            "Edit",
817            "MultiEdit",
818            "NotebookEdit",
819            "edit_file",
820        ],
821    ),
822    ("bash", &["bash", "Bash", "shell"]),
823    ("web_fetch", &["web_fetch", "WebFetch", "webfetch"]),
824];
825
826/// Returns true iff `source` represents a direct tool attribution that
827/// should count toward `tool_usage.actual`.
828///
829/// Direct attribution sources -- a real tool fired and the channel
830/// captured it:
831///   - `hook`              integration hook saw the tool fire
832///   - `mcp`               promoted from MCP-bridge agent.called_tool
833///   - `shell-wrap`        `treeship wrap` captured a shell command
834///   - `session-event-cli` `treeship session event` from a hook script.
835///                         The Claude Code plugin's PostToolUse hook
836///                         calls `treeship session event --type
837///                         agent.wrote_file --file X`, and the CLI
838///                         tags those as "session-event-cli" -- so
839///                         excluding this label would make every
840///                         claude-code-plugin event invisible to
841///                         cross-verify.
842///   - None                legacy untagged event (back-compat)
843///
844/// Backstop / inference sources -- a file changed but no tool was
845/// directly attributed. Surface in the receipt's "Files changed"
846/// section so the reader sees the change but they must NOT inflate
847/// tool_usage:
848///   - `git-reconcile`     git diff at session close
849///   - `daemon-atime`      atime-based file detection
850fn source_attributes_a_tool(source: Option<&str>) -> bool {
851    matches!(
852        source,
853        None | Some("hook") | Some("mcp") | Some("shell-wrap") | Some("session-event-cli"),
854    )
855}
856
857/// Counts side effects by canonical tool name, filtering out
858/// non-attribution sources (git-reconcile, daemon-atime).
859fn count_attributed<'a, F>(
860    items: usize,
861    source_at: F,
862    canonical: &str,
863    counts: &mut std::collections::BTreeMap<String, u32>,
864) where
865    F: Fn(usize) -> Option<&'a str>,
866{
867    let n: u32 = (0..items)
868        .filter(|i| source_attributes_a_tool(source_at(*i)))
869        .count() as u32;
870    if n > 0 {
871        *counts.entry(canonical.to_string()).or_insert(0) += n;
872    }
873}
874
875fn derive_tool_usage(
876    side_effects: &SideEffects,
877    authorized_tools: &[String],
878    network_scope: &[String],
879) -> Option<ToolUsage> {
880    use std::collections::BTreeMap;
881
882    let total_specialized = side_effects.files_read.len()
883        + side_effects.files_written.len()
884        + side_effects.processes.len()
885        + side_effects.network_connections.len();
886
887    if side_effects.tool_invocations.is_empty()
888        && total_specialized == 0
889        && authorized_tools.is_empty()
890        && network_scope.is_empty()
891    {
892        return None;
893    }
894
895    let mut counts: BTreeMap<String, u32> = BTreeMap::new();
896
897    // Generic agent.called_tool events use the tool's actual name.
898    // The MCP bridge writes meta.source = "mcp-bridge" (which is not
899    // in source_attributes_a_tool's allow list) but tool_invocations
900    // come ONLY from agent.called_tool, which is direct attribution
901    // by definition -- so count all of them, no source filter applies
902    // here. (The bridge tool name is the source.)
903    for inv in &side_effects.tool_invocations {
904        *counts.entry(inv.tool_name.clone()).or_insert(0) += 1;
905    }
906
907    // Specialized side effects, source-filtered: only direct
908    // attribution (hook / mcp / shell-wrap / untagged-legacy) counts.
909    // git-reconcile and friends surface in the "Files changed" section
910    // for the reader but do NOT inflate tool_usage.
911    let fr = &side_effects.files_read;
912    count_attributed(
913        fr.len(),
914        |i| fr[i].source.as_deref(),
915        "read_file",
916        &mut counts,
917    );
918    let fw = &side_effects.files_written;
919    count_attributed(
920        fw.len(),
921        |i| fw[i].source.as_deref(),
922        "write_file",
923        &mut counts,
924    );
925    let pr = &side_effects.processes;
926    count_attributed(pr.len(), |i| pr[i].source.as_deref(), "bash", &mut counts);
927    // network_connections has no source field today; treat all as
928    // attributed (this matches the round-1 behavior since there's no
929    // backstop layer producing network entries).
930    if !side_effects.network_connections.is_empty() {
931        *counts.entry("web_fetch".to_string()).or_insert(0) +=
932            side_effects.network_connections.len() as u32;
933    }
934
935    let actual: Vec<ToolUsageEntry> = counts
936        .iter()
937        .map(|(name, &count)| ToolUsageEntry {
938            tool_name: name.clone(),
939            count,
940        })
941        .collect();
942
943    // Authorization check uses alias resolution: an actual tool is
944    // unauthorized only if NONE of its aliases are in the declared
945    // list. So a declaration of "read_file" authorizes both "Read"
946    // (Claude convention) and "read_file" (CLI convention) when they
947    // produce the canonical "read_file" actual entry.
948    let unauthorized = if authorized_tools.is_empty() {
949        Vec::new()
950    } else {
951        let declared_set: std::collections::BTreeSet<&str> =
952            authorized_tools.iter().map(|s| s.as_str()).collect();
953        counts
954            .keys()
955            .filter(|actual_name| !is_authorized(actual_name, &declared_set))
956            .cloned()
957            .collect()
958    };
959
960    // Network scope: every destination the session connected to, judged
961    // against the declared patterns. No scope declared means no judgement,
962    // never "all clear": the fields stay absent and the connections stand
963    // in side_effects as recorded.
964    let network_off_scope: Vec<String> = if network_scope.is_empty() {
965        Vec::new()
966    } else {
967        let mut off: Vec<String> = side_effects
968            .network_connections
969            .iter()
970            .map(|c| c.destination.clone())
971            .filter(|d| !host_in_scope(d, network_scope))
972            .collect();
973        off.sort();
974        off.dedup();
975        off
976    };
977
978    Some(ToolUsage {
979        declared: authorized_tools.to_vec(),
980        actual,
981        unauthorized,
982        network_declared: network_scope.to_vec(),
983        network_off_scope,
984    })
985}
986
987/// Returns true if `actual_name` (or any of its declared aliases) is
988/// in the declared set. Aliases mean a cert can use either Claude
989/// convention or snake_case CLI convention and still authorize the
990/// same canonical bucket.
991fn is_authorized(actual_name: &str, declared_set: &std::collections::BTreeSet<&str>) -> bool {
992    // Direct hit: the declared set names this tool exactly.
993    if declared_set.contains(actual_name) {
994        return true;
995    }
996    // Alias hit: walk the canonical mapping and see if any alias of
997    // the canonical bucket the actual_name belongs to is in declared.
998    for (canonical, aliases) in TOOL_ALIASES {
999        if *canonical == actual_name || aliases.contains(&actual_name) {
1000            for alias in *aliases {
1001                if declared_set.contains(*alias) {
1002                    return true;
1003                }
1004            }
1005            return false;
1006        }
1007    }
1008    false
1009}
1010
1011fn event_type_label(et: &super::event::EventType) -> String {
1012    use super::event::EventType::*;
1013    match et {
1014        SessionStarted => "session.started",
1015        SessionClosed { .. } => "session.closed",
1016        AgentStarted { .. } => "agent.started",
1017        AgentSpawned { .. } => "agent.spawned",
1018        AgentHandoff { .. } => "agent.handoff",
1019        AgentCollaborated { .. } => "agent.collaborated",
1020        AgentReturned { .. } => "agent.returned",
1021        AgentCompleted { .. } => "agent.completed",
1022        AgentFailed { .. } => "agent.failed",
1023        AgentCalledTool { .. } => "agent.called_tool",
1024        AgentReadFile { .. } => "agent.read_file",
1025        AgentWroteFile { .. } => "agent.wrote_file",
1026        AgentOpenedPort { .. } => "agent.opened_port",
1027        AgentConnectedNetwork { .. } => "agent.connected_network",
1028        AgentStartedProcess { .. } => "agent.started_process",
1029        AgentCompletedProcess { .. } => "agent.completed_process",
1030        AgentDecision { .. } => "agent.decision",
1031        AgentNote { .. } => "agent.note",
1032    }
1033    .into()
1034}
1035
1036/// Optional human-readable summary from an EventType.
1037fn event_summary(et: &super::event::EventType) -> Option<String> {
1038    use super::event::EventType::*;
1039    match et {
1040        SessionStarted => Some("Session started".into()),
1041        SessionClosed { summary, .. } => summary.clone().or(Some("Session closed".into())),
1042        AgentSpawned { reason, .. } => reason.clone(),
1043        AgentHandoff {
1044            from_agent_instance_id,
1045            to_agent_instance_id,
1046            ..
1047        } => Some(format!(
1048            "{from_agent_instance_id} -> {to_agent_instance_id}"
1049        )),
1050        AgentNote { text } => text.clone(),
1051        AgentCalledTool { tool_name, .. } => Some(format!("Called {tool_name}")),
1052        AgentReadFile { file_path, .. } => Some(format!("Read {file_path}")),
1053        AgentWroteFile { file_path, .. } => Some(format!("Wrote {file_path}")),
1054        AgentOpenedPort { port, .. } => Some(format!("Opened port {port}")),
1055        AgentConnectedNetwork { destination, .. } => Some(format!("Connected to {destination}")),
1056        AgentStartedProcess { process_name, .. } => Some(format!("Started {process_name}")),
1057        AgentCompletedProcess {
1058            process_name,
1059            exit_code,
1060            ..
1061        } => Some(format!(
1062            "Completed {process_name} (exit {})",
1063            exit_code.unwrap_or(-1)
1064        )),
1065        AgentCompleted { termination_reason } => termination_reason
1066            .clone()
1067            .or(Some("Agent completed".into())),
1068        AgentFailed { reason } => reason.clone().or(Some("Agent failed".into())),
1069        AgentDecision {
1070            model,
1071            summary,
1072            provider,
1073            ..
1074        } => {
1075            let mut parts = Vec::new();
1076            if let Some(s) = summary {
1077                parts.push(s.clone());
1078            }
1079            if let Some(m) = model {
1080                parts.push(format!("model: {m}"));
1081            }
1082            if let Some(p) = provider {
1083                parts.push(format!("via {p}"));
1084            }
1085            if parts.is_empty() {
1086                Some("LLM decision".into())
1087            } else {
1088                Some(parts.join(" | "))
1089            }
1090        }
1091        _ => None,
1092    }
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097    use super::*;
1098    use crate::session::event::*;
1099
1100    fn make_manifest() -> SessionManifest {
1101        SessionManifest::new(
1102            "ssn_001".into(),
1103            "agent://test".into(),
1104            "2026-04-05T08:00:00Z".into(),
1105            1743843600000,
1106        )
1107    }
1108
1109    /// Module-level event constructor so the tool-authorization regression
1110    /// tests below can reuse it without each redefining the closure.
1111    fn mk(seq: u64, inst: &str, et: EventType) -> SessionEvent {
1112        SessionEvent {
1113            session_id: "ssn_001".into(),
1114            event_id: format!("evt_{:016x}", seq),
1115            timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
1116            sequence_no: seq,
1117            trace_id: "trace_1".into(),
1118            span_id: format!("span_{seq}"),
1119            parent_span_id: None,
1120            agent_id: format!("agent://{inst}"),
1121            agent_instance_id: inst.into(),
1122            agent_name: inst.into(),
1123            agent_role: None,
1124            host_id: "host_1".into(),
1125            tool_runtime_id: None,
1126            event_type: et,
1127            artifact_ref: None,
1128            meta: None,
1129        }
1130    }
1131
1132    fn make_events() -> Vec<SessionEvent> {
1133        vec![
1134            mk(0, "root", EventType::SessionStarted),
1135            mk(
1136                1,
1137                "root",
1138                EventType::AgentStarted {
1139                    parent_agent_instance_id: None,
1140                },
1141            ),
1142            mk(
1143                2,
1144                "worker",
1145                EventType::AgentSpawned {
1146                    spawned_by_agent_instance_id: "root".into(),
1147                    reason: Some("review".into()),
1148                },
1149            ),
1150            mk(
1151                3,
1152                "worker",
1153                EventType::AgentCalledTool {
1154                    tool_name: "read_file".into(),
1155                    tool_input_digest: None,
1156                    tool_output_digest: None,
1157                    duration_ms: Some(5),
1158                },
1159            ),
1160            mk(
1161                4,
1162                "worker",
1163                EventType::AgentWroteFile {
1164                    file_path: "src/fix.rs".into(),
1165                    digest: None,
1166                    operation: None,
1167                    additions: None,
1168                    deletions: None,
1169                },
1170            ),
1171            mk(
1172                5,
1173                "worker",
1174                EventType::AgentCompleted {
1175                    termination_reason: None,
1176                },
1177            ),
1178            mk(
1179                6,
1180                "root",
1181                EventType::SessionClosed {
1182                    summary: Some("Done".into()),
1183                    duration_ms: Some(360000),
1184                },
1185            ),
1186        ]
1187    }
1188
1189    #[test]
1190    fn compose_receipt() {
1191        let manifest = make_manifest();
1192        let events = make_events();
1193        let artifacts = vec![
1194            ArtifactEntry {
1195                artifact_id: "art_001".into(),
1196                payload_type: "action".into(),
1197                digest: None,
1198                signed_at: None,
1199                unchained: false,
1200            },
1201            ArtifactEntry {
1202                artifact_id: "art_002".into(),
1203                payload_type: "action".into(),
1204                digest: None,
1205                signed_at: None,
1206                unchained: false,
1207            },
1208        ];
1209
1210        let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
1211
1212        assert_eq!(receipt.type_, RECEIPT_TYPE);
1213        assert_eq!(receipt.session.id, "ssn_001");
1214        assert_eq!(receipt.timeline.len(), 7);
1215        assert_eq!(receipt.agent_graph.nodes.len(), 2); // root + worker
1216        assert_eq!(receipt.side_effects.files_written.len(), 1);
1217        assert_eq!(receipt.merkle.leaf_count, 2);
1218        assert!(receipt.merkle.root.is_some());
1219    }
1220
1221    #[test]
1222    fn composed_receipt_mirrors_bound_workflow_reference() {
1223        let mut manifest = make_manifest();
1224        manifest.workflow_ref = Some("art_0123456789abcdef0123456789abcdef".into());
1225
1226        let receipt = ReceiptComposer::compose(&manifest, &make_events(), vec![]);
1227
1228        assert_eq!(
1229            receipt.session.workflow_ref.as_deref(),
1230            Some("art_0123456789abcdef0123456789abcdef")
1231        );
1232        let json = ReceiptComposer::to_canonical_json(&receipt).unwrap();
1233        assert!(String::from_utf8(json)
1234            .unwrap()
1235            .contains(r#""workflow_ref":"art_0123456789abcdef0123456789abcdef""#));
1236    }
1237
1238    #[test]
1239    fn new_receipts_carry_schema_version() {
1240        let manifest = make_manifest();
1241        let events = make_events();
1242        let artifacts = vec![ArtifactEntry {
1243            artifact_id: "art_001".into(),
1244            payload_type: "action".into(),
1245            digest: None,
1246            signed_at: None,
1247            unchained: false,
1248        }];
1249        let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
1250        assert_eq!(
1251            receipt.schema_version.as_deref(),
1252            Some(RECEIPT_SCHEMA_VERSION)
1253        );
1254        // And it shows up in canonical JSON.
1255        let json =
1256            String::from_utf8(ReceiptComposer::to_canonical_json(&receipt).unwrap()).unwrap();
1257        assert!(
1258            json.contains(r#""schema_version":"1""#),
1259            "missing schema_version: {json}"
1260        );
1261    }
1262
1263    #[test]
1264    fn legacy_receipt_without_schema_version_round_trips_byte_identical() {
1265        // Simulate a pre-v0.9.0 receipt by composing one and stripping the
1266        // schema_version field. Re-serializing must produce byte-identical
1267        // output so the package-level determinism check keeps passing for
1268        // old receipts that nobody can re-sign.
1269        let manifest = make_manifest();
1270        let events = make_events();
1271        let artifacts = vec![ArtifactEntry {
1272            artifact_id: "art_001".into(),
1273            payload_type: "action".into(),
1274            digest: None,
1275            signed_at: None,
1276            unchained: false,
1277        }];
1278        let mut receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
1279        receipt.schema_version = None; // mimic a legacy receipt
1280
1281        let original = ReceiptComposer::to_canonical_json(&receipt).unwrap();
1282        // Verify the field is omitted, not serialized as null.
1283        let original_str = std::str::from_utf8(&original).unwrap();
1284        assert!(
1285            !original_str.contains("schema_version"),
1286            "schema_version must be skipped when None"
1287        );
1288
1289        let parsed: SessionReceipt = serde_json::from_slice(&original).unwrap();
1290        assert!(
1291            parsed.schema_version.is_none(),
1292            "legacy receipts must parse with schema_version=None"
1293        );
1294
1295        let reserialized = ReceiptComposer::to_canonical_json(&parsed).unwrap();
1296        assert_eq!(
1297            original, reserialized,
1298            "legacy receipt must round-trip byte-identical so package determinism check passes"
1299        );
1300    }
1301
1302    #[test]
1303    fn canonical_json_is_deterministic() {
1304        let manifest = make_manifest();
1305        let events = make_events();
1306        let artifacts = vec![ArtifactEntry {
1307            artifact_id: "art_001".into(),
1308            payload_type: "action".into(),
1309            digest: None,
1310            signed_at: None,
1311            unchained: false,
1312        }];
1313
1314        let r1 = ReceiptComposer::compose(&manifest, &events, artifacts.clone());
1315        let r2 = ReceiptComposer::compose(&manifest, &events, artifacts);
1316
1317        let j1 = ReceiptComposer::to_canonical_json(&r1).unwrap();
1318        let j2 = ReceiptComposer::to_canonical_json(&r2).unwrap();
1319        assert_eq!(j1, j2);
1320
1321        let d1 = ReceiptComposer::digest(&r1).unwrap();
1322        let d2 = ReceiptComposer::digest(&r2).unwrap();
1323        assert_eq!(d1, d2);
1324    }
1325
1326    // ── Tool authorization regression tests (Codex finding #1) ──
1327    //
1328    // Specialized event types (agent.wrote_file, agent.completed_process,
1329    // agent.read_file) must contribute to tool_usage.actual so that a
1330    // certificate's bounded_actions list can correctly flag unauthorized
1331    // built-in tool usage. Before this fix, only agent.called_tool fed
1332    // tool_usage.actual, so a cert that omitted "Bash" still passed even
1333    // when the agent ran Bash via Claude Code's built-in.
1334
1335    fn manifest_with_authorized(tools: Vec<&str>) -> SessionManifest {
1336        let mut m = make_manifest();
1337        m.authorized_tools = tools.into_iter().map(String::from).collect();
1338        m
1339    }
1340
1341    #[test]
1342    fn cert_omitting_bash_flags_unauthorized_when_session_runs_bash() {
1343        // Cert uses CLI-documented snake_case names (declare.rs:80,
1344        // main.rs:226). Round-2 fix: canonical actual is "bash" not
1345        // "Bash"; round-1 was flagging mismatches the wrong way.
1346        let manifest = manifest_with_authorized(vec!["read_file", "write_file"]); // NO bash
1347        let events = vec![
1348            mk(0, "root", EventType::SessionStarted),
1349            mk(
1350                1,
1351                "agent",
1352                EventType::AgentCompletedProcess {
1353                    process_name: "rm -rf /".into(),
1354                    exit_code: Some(0),
1355                    duration_ms: Some(50),
1356                    command: Some("rm -rf /".into()),
1357                },
1358            ),
1359            mk(
1360                2,
1361                "root",
1362                EventType::SessionClosed {
1363                    summary: None,
1364                    duration_ms: Some(1000),
1365                },
1366            ),
1367        ];
1368        let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1369        let tu = receipt.tool_usage.expect("tool_usage must be populated");
1370        assert!(
1371            tu.unauthorized.iter().any(|t| t == "bash"),
1372            "bash must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
1373            tu.unauthorized, tu.actual,
1374        );
1375    }
1376
1377    #[test]
1378    fn cert_omitting_write_flags_unauthorized_when_session_writes_file() {
1379        let manifest = manifest_with_authorized(vec!["read_file", "bash"]); // NO write_file
1380        let events = vec![
1381            mk(0, "root", EventType::SessionStarted),
1382            mk(
1383                1,
1384                "agent",
1385                EventType::AgentWroteFile {
1386                    file_path: "src/secret.rs".into(),
1387                    digest: None,
1388                    operation: Some("modified".into()),
1389                    additions: Some(10),
1390                    deletions: Some(0),
1391                },
1392            ),
1393            mk(
1394                2,
1395                "root",
1396                EventType::SessionClosed {
1397                    summary: None,
1398                    duration_ms: Some(1000),
1399                },
1400            ),
1401        ];
1402        let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1403        let tu = receipt.tool_usage.expect("tool_usage must be populated");
1404        assert!(
1405            tu.unauthorized.iter().any(|t| t == "write_file"),
1406            "write_file must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
1407            tu.unauthorized, tu.actual,
1408        );
1409    }
1410
1411    #[test]
1412    fn cert_includes_read_write_bash_passes_clean_when_all_used() {
1413        let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]);
1414        let events = vec![
1415            mk(0, "root", EventType::SessionStarted),
1416            mk(
1417                1,
1418                "agent",
1419                EventType::AgentReadFile {
1420                    file_path: "package.json".into(),
1421                    digest: None,
1422                },
1423            ),
1424            mk(
1425                2,
1426                "agent",
1427                EventType::AgentWroteFile {
1428                    file_path: "src/lib.rs".into(),
1429                    digest: None,
1430                    operation: Some("modified".into()),
1431                    additions: Some(5),
1432                    deletions: Some(2),
1433                },
1434            ),
1435            mk(
1436                3,
1437                "agent",
1438                EventType::AgentCompletedProcess {
1439                    process_name: "bun test".into(),
1440                    exit_code: Some(0),
1441                    duration_ms: Some(2000),
1442                    command: Some("bun test".into()),
1443                },
1444            ),
1445            mk(
1446                4,
1447                "root",
1448                EventType::SessionClosed {
1449                    summary: None,
1450                    duration_ms: Some(5000),
1451                },
1452            ),
1453        ];
1454        let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1455        let tu = receipt.tool_usage.expect("tool_usage must be populated");
1456        assert!(
1457            tu.unauthorized.is_empty(),
1458            "all tools declared in cert should pass clean; got unauthorized={:?}",
1459            tu.unauthorized,
1460        );
1461        // The actual list uses canonical lowercase names that match what
1462        // `treeship declare --tools` and `treeship agent register --tools`
1463        // teach (declare.rs:80, main.rs:226).
1464        let actual_names: std::collections::BTreeSet<String> =
1465            tu.actual.iter().map(|e| e.tool_name.clone()).collect();
1466        assert!(actual_names.contains("read_file"));
1467        assert!(actual_names.contains("write_file"));
1468        assert!(actual_names.contains("bash"));
1469    }
1470
1471    #[test]
1472    fn webfetch_unauthorized_flagged_when_cert_omits_it() {
1473        let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]); // NO web_fetch
1474        let events = vec![
1475            mk(0, "root", EventType::SessionStarted),
1476            mk(
1477                1,
1478                "agent",
1479                EventType::AgentConnectedNetwork {
1480                    destination: "evil.example.com".into(),
1481                    port: Some(443),
1482                },
1483            ),
1484            mk(
1485                2,
1486                "root",
1487                EventType::SessionClosed {
1488                    summary: None,
1489                    duration_ms: Some(1000),
1490                },
1491            ),
1492        ];
1493        let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1494        let tu = receipt.tool_usage.expect("tool_usage must be populated");
1495        assert!(
1496            tu.unauthorized.iter().any(|t| t == "web_fetch"),
1497            "web_fetch must be flagged as unauthorized when cert omits it; got unauthorized={:?}",
1498            tu.unauthorized,
1499        );
1500    }
1501
1502    fn manifest_with_network(scope: Vec<&str>) -> SessionManifest {
1503        let mut m = make_manifest();
1504        m.network_scope = scope.into_iter().map(String::from).collect();
1505        m
1506    }
1507
1508    fn net(seq: u64, host: &str) -> SessionEvent {
1509        mk(
1510            seq,
1511            "agent",
1512            EventType::AgentConnectedNetwork {
1513                destination: host.into(),
1514                port: Some(443),
1515            },
1516        )
1517    }
1518
1519    #[test]
1520    fn host_in_scope_matches_exact_wildcard_and_any() {
1521        let scope = vec!["api.example.com".to_string(), "*.internal.net".to_string()];
1522        assert!(host_in_scope("api.example.com", &scope));
1523        assert!(host_in_scope("API.Example.com.", &scope));
1524        assert!(host_in_scope("internal.net", &scope));
1525        assert!(host_in_scope("db.internal.net", &scope));
1526        assert!(!host_in_scope("evil.example.com", &scope));
1527        assert!(!host_in_scope("notinternal.net", &scope));
1528        assert!(!host_in_scope("", &scope));
1529        assert!(host_in_scope("anything.at.all", &["*".to_string()]));
1530        assert!(!host_in_scope("anything.at.all", &[]));
1531    }
1532
1533    #[test]
1534    fn declared_network_scope_flags_off_scope_destinations() {
1535        let manifest = manifest_with_network(vec!["api.example.com", "*.internal.net"]);
1536        let events = vec![
1537            mk(0, "root", EventType::SessionStarted),
1538            net(1, "api.example.com"),
1539            net(2, "db.internal.net"),
1540            net(3, "evil.example.com"),
1541            net(4, "evil.example.com"),
1542            mk(
1543                5,
1544                "root",
1545                EventType::SessionClosed {
1546                    summary: None,
1547                    duration_ms: Some(1000),
1548                },
1549            ),
1550        ];
1551        let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1552        let tu = receipt.tool_usage.expect("tool_usage must be populated");
1553        assert_eq!(
1554            tu.network_declared,
1555            vec!["api.example.com".to_string(), "*.internal.net".to_string()]
1556        );
1557        assert_eq!(tu.network_off_scope, vec!["evil.example.com".to_string()]);
1558    }
1559
1560    #[test]
1561    fn no_network_scope_means_no_judgement() {
1562        let manifest = make_manifest();
1563        let events = vec![
1564            mk(0, "root", EventType::SessionStarted),
1565            net(1, "evil.example.com"),
1566            mk(
1567                2,
1568                "root",
1569                EventType::SessionClosed {
1570                    summary: None,
1571                    duration_ms: Some(1000),
1572                },
1573            ),
1574        ];
1575        let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1576        let tu = receipt.tool_usage.expect("tool_usage must be populated");
1577        assert!(tu.network_declared.is_empty());
1578        assert!(
1579            tu.network_off_scope.is_empty(),
1580            "nothing declared, nothing judged"
1581        );
1582        assert_eq!(
1583            receipt.side_effects.network_connections.len(),
1584            1,
1585            "but the connection is recorded"
1586        );
1587    }
1588
1589    // ── Round-2 fix tests: alias matching + source filtering ──
1590
1591    fn evt_with_source(event_type: EventType, source: &str) -> SessionEvent {
1592        let mut e = mk(99, "agent", event_type);
1593        e.meta = Some(serde_json::json!({"source": source}));
1594        e
1595    }
1596
1597    #[test]
1598    fn titlecase_cert_authorizes_canonical_snake_actuals_via_alias() {
1599        // Operator declares Claude convention. Aliases map "Read" to
1600        // canonical "read_file", "Write" to "write_file", etc.
1601        let manifest = manifest_with_authorized(vec!["Read", "Write", "Bash"]);
1602        let events = vec![
1603            mk(0, "root", EventType::SessionStarted),
1604            mk(
1605                1,
1606                "agent",
1607                EventType::AgentReadFile {
1608                    file_path: "x".into(),
1609                    digest: None,
1610                },
1611            ),
1612            mk(
1613                2,
1614                "agent",
1615                EventType::AgentWroteFile {
1616                    file_path: "y".into(),
1617                    digest: None,
1618                    operation: None,
1619                    additions: None,
1620                    deletions: None,
1621                },
1622            ),
1623            mk(
1624                3,
1625                "agent",
1626                EventType::AgentCompletedProcess {
1627                    process_name: "z".into(),
1628                    exit_code: Some(0),
1629                    duration_ms: Some(1),
1630                    command: None,
1631                },
1632            ),
1633            mk(
1634                4,
1635                "root",
1636                EventType::SessionClosed {
1637                    summary: None,
1638                    duration_ms: Some(1000),
1639                },
1640            ),
1641        ];
1642        let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1643            .tool_usage
1644            .unwrap();
1645        assert!(
1646            tu.unauthorized.is_empty(),
1647            "TitleCase declarations must authorize canonical snake_case actuals via aliases; \
1648             got unauthorized={:?}",
1649            tu.unauthorized,
1650        );
1651    }
1652
1653    #[test]
1654    fn edit_alias_authorizes_specialized_wrote_file() {
1655        // Operator declares "Edit" specifically. post-tool-use.sh
1656        // emits agent.wrote_file for Edit/MultiEdit alike, so the
1657        // canonical actual is "write_file". Edit is in the write_file
1658        // alias list, so the cert authorizes.
1659        let manifest = manifest_with_authorized(vec!["Edit"]);
1660        let events = vec![
1661            mk(0, "root", EventType::SessionStarted),
1662            mk(
1663                1,
1664                "agent",
1665                EventType::AgentWroteFile {
1666                    file_path: "x".into(),
1667                    digest: None,
1668                    operation: None,
1669                    additions: None,
1670                    deletions: None,
1671                },
1672            ),
1673            mk(
1674                2,
1675                "root",
1676                EventType::SessionClosed {
1677                    summary: None,
1678                    duration_ms: Some(1000),
1679                },
1680            ),
1681        ];
1682        let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1683            .tool_usage
1684            .unwrap();
1685        assert!(
1686            tu.unauthorized.is_empty(),
1687            "Edit alias must authorize write_file"
1688        );
1689    }
1690
1691    #[test]
1692    fn git_reconcile_writes_dont_count_toward_tool_usage() {
1693        // Backstop evidence -- not direct tool attribution.
1694        // A git-reconciled change must NOT make the cert require
1695        // write_file authorization, because no Write tool was invoked.
1696        let manifest = manifest_with_authorized(vec!["read_file"]);
1697        let events = vec![
1698            mk(0, "root", EventType::SessionStarted),
1699            evt_with_source(
1700                EventType::AgentWroteFile {
1701                    file_path: "CHANGELOG.md".into(),
1702                    digest: None,
1703                    operation: Some("modified".into()),
1704                    additions: Some(7),
1705                    deletions: Some(2),
1706                },
1707                "git-reconcile",
1708            ),
1709            mk(
1710                2,
1711                "root",
1712                EventType::SessionClosed {
1713                    summary: None,
1714                    duration_ms: Some(1000),
1715                },
1716            ),
1717        ];
1718        let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1719            .tool_usage
1720            .unwrap();
1721        assert!(
1722            !tu.unauthorized.iter().any(|t| t == "write_file"),
1723            "git-reconcile entries must NOT count toward tool_usage; \
1724             got unauthorized={:?}, actual={:?}",
1725            tu.unauthorized,
1726            tu.actual,
1727        );
1728        let actual_names: std::collections::BTreeSet<String> =
1729            tu.actual.iter().map(|e| e.tool_name.clone()).collect();
1730        assert!(
1731            !actual_names.contains("write_file"),
1732            "actual must not include backstop-only writes"
1733        );
1734    }
1735
1736    // session-event-cli is a direct-attribution source -- the standard
1737    // label the CLI stamps on events emitted by claude-code-plugin's
1738    // PostToolUse hook. So it counts toward tool_usage.actual just like
1739    // hook/mcp/shell-wrap do. The end-to-end test for this lives in
1740    // the targeted acceptance suite (T1) rather than as a unit test
1741    // here, because it requires the full event-emission + receipt-
1742    // composition pipeline running through `treeship session event`.
1743
1744    #[test]
1745    fn hook_emitted_writes_still_count_toward_tool_usage() {
1746        // Positive case: regular hook-emitted write IS direct attribution.
1747        let manifest = manifest_with_authorized(vec!["read_file"]); // NO write_file
1748        let events = vec![
1749            mk(0, "root", EventType::SessionStarted),
1750            evt_with_source(
1751                EventType::AgentWroteFile {
1752                    file_path: "src/x.rs".into(),
1753                    digest: None,
1754                    operation: None,
1755                    additions: None,
1756                    deletions: None,
1757                },
1758                "hook",
1759            ),
1760            mk(
1761                2,
1762                "root",
1763                EventType::SessionClosed {
1764                    summary: None,
1765                    duration_ms: Some(1000),
1766                },
1767            ),
1768        ];
1769        let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1770            .tool_usage
1771            .unwrap();
1772        assert!(
1773            tu.unauthorized.iter().any(|t| t == "write_file"),
1774            "hook-emitted writes MUST count toward tool_usage; got unauthorized={:?}",
1775            tu.unauthorized,
1776        );
1777    }
1778
1779    #[test]
1780    fn legacy_untagged_writes_count_for_back_compat() {
1781        // Pre-v0.9.6 events have no source tag. Treat as attributed
1782        // (back-compat: receipts produced before source labeling existed).
1783        let manifest = manifest_with_authorized(vec!["read_file"]); // NO write_file
1784        let events = vec![
1785            mk(0, "root", EventType::SessionStarted),
1786            mk(
1787                1,
1788                "agent",
1789                EventType::AgentWroteFile {
1790                    file_path: "x".into(),
1791                    digest: None,
1792                    operation: None,
1793                    additions: None,
1794                    deletions: None,
1795                },
1796            ),
1797            mk(
1798                2,
1799                "root",
1800                EventType::SessionClosed {
1801                    summary: None,
1802                    duration_ms: Some(1000),
1803                },
1804            ),
1805        ];
1806        let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1807            .tool_usage
1808            .unwrap();
1809        assert!(
1810            tu.unauthorized.iter().any(|t| t == "write_file"),
1811            "legacy untagged writes must count for back-compat",
1812        );
1813    }
1814}
1815
1816#[cfg(test)]
1817mod custody_tests {
1818    use super::*;
1819
1820    /// Self-custody is absence, not a value. Existing receipts must stay
1821    /// byte-identical, and a missing block must not read as "unknown".
1822    #[test]
1823    fn self_custody_serializes_to_nothing() {
1824        let c: Option<Custody> = None;
1825        let json = serde_json::to_string(&serde_json::json!({ "custody": c })).unwrap();
1826        assert_eq!(json, r#"{"custody":null}"#);
1827        // ...and on the real struct the field is skipped entirely:
1828        #[derive(Serialize)]
1829        struct Holder {
1830            #[serde(default, skip_serializing_if = "Option::is_none")]
1831            custody: Option<Custody>,
1832        }
1833        let s = serde_json::to_string(&Holder { custody: None }).unwrap();
1834        assert_eq!(s, "{}", "self-custody must add no bytes");
1835    }
1836
1837    /// A delegated receipt must name BOTH parties. Recording only the actor
1838    /// would present a service signature as the actor's own; recording only
1839    /// the signer would lose who the claim is about.
1840    #[test]
1841    fn delegated_custody_names_signer_and_subject() {
1842        let c = Custody::delegated("svc://gateway-rooms", "agent://fizz")
1843            .with_reason("browser-mediated room; participants hold no local key");
1844        let v = serde_json::to_value(&c).unwrap();
1845        assert_eq!(v["mode"], "delegated");
1846        assert_eq!(v["signer"], "svc://gateway-rooms");
1847        assert_eq!(v["on_behalf_of"], "agent://fizz");
1848        assert!(v["reason"].as_str().unwrap().contains("no local key"));
1849    }
1850
1851    #[test]
1852    fn reason_is_optional_and_omitted_when_unset() {
1853        let c = Custody::delegated("svc://x", "agent://y");
1854        let v = serde_json::to_value(&c).unwrap();
1855        assert!(v.get("reason").is_none(), "unset reason must not serialize");
1856    }
1857
1858    /// Round-trips through JSON unchanged -- this rides inside signed bytes,
1859    /// so a lossy field would break verification, not just display.
1860    #[test]
1861    fn custody_round_trips() {
1862        let c = Custody::delegated("svc://gateway-rooms", "agent://fizz").with_reason("r");
1863        let back: Custody = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
1864        assert_eq!(c, back);
1865    }
1866
1867    /// Custody and attestation_class are independent axes. A service-signed
1868    /// receipt can carry runtime-captured evidence -- good evidence, delegated
1869    /// key -- and the two labels must not be inferred from each other.
1870    #[test]
1871    fn custody_is_orthogonal_to_evidence_capture() {
1872        let receipt = serde_json::json!({
1873            "attestation_class": "runtime",
1874            "custody": Custody::delegated("svc://gateway-rooms", "agent://fizz"),
1875        });
1876        assert_eq!(receipt["attestation_class"], "runtime");
1877        assert_eq!(receipt["custody"]["mode"], "delegated");
1878    }
1879}
1880
1881#[cfg(test)]
1882mod custody_wiring_tests {
1883    use super::*;
1884
1885    /// The schema is the gate: `validate("session.v1", ..)` runs fail-closed
1886    /// before anything is signed, so a custody block the schema rejects would
1887    /// mean a service literally cannot emit an honest receipt. That is the
1888    /// state this test exists to prevent -- the type existed for a while with
1889    /// no schema entry, which made it decorative.
1890    #[test]
1891    fn a_delegated_receipt_passes_predicate_validation() {
1892        let payload = serde_json::json!({
1893            "session_id": "ssn_room_demo",
1894            "actor": "agent://fizz",
1895            "outcome": "completed",
1896            "started_at": "2026-08-10T10:00:00Z",
1897            "closed_at": "2026-08-10T10:30:00Z",
1898            "attestation_class": "runtime",
1899            "receipt_digest": format!("sha256:{}", "a".repeat(64)),
1900            "custody": {
1901                "mode": "delegated",
1902                "signer": "svc://gateway-rooms",
1903                "on_behalf_of": "agent://fizz",
1904                "reason": "browser-mediated room; participants hold no local key"
1905            }
1906        });
1907        crate::predicates::validate("session.v1", Some(&payload))
1908            .expect("a delegated-custody receipt must validate");
1909    }
1910
1911    /// Self-custody stays byte-identical: no block, no schema objection.
1912    #[test]
1913    fn a_self_custody_receipt_still_validates() {
1914        let payload = serde_json::json!({
1915            "session_id": "ssn_plain",
1916            "actor": "ship://local",
1917            "outcome": "completed",
1918            "started_at": "2026-08-10T10:00:00Z",
1919            "closed_at": "2026-08-10T10:30:00Z",
1920            "attestation_class": "self",
1921            "receipt_digest": format!("sha256:{}", "b".repeat(64)),
1922        });
1923        crate::predicates::validate("session.v1", Some(&payload))
1924            .expect("a self-custody receipt must validate");
1925    }
1926
1927    /// A custody block missing `signer` names no one -- recording "delegated"
1928    /// without saying delegated to WHOM is worse than omitting it, because it
1929    /// tells a reader the actor did not sign while withholding who did.
1930    ///
1931    /// Core's validator walks the whole tree since 0.31.6, so the `required`
1932    /// list inside the custody schema is enforced before signing: a custody
1933    /// block without a `signer` is refused with a dotted field path. The Rust
1934    /// type still holds the invariant one layer down (`signer` and
1935    /// `on_behalf_of` are `String`, not `Option<String>`), so a `Custody`
1936    /// cannot be constructed without them either. This test pins both.
1937    #[test]
1938    fn custody_requires_a_signer_by_type_and_by_validator() {
1939        // The type will not let you omit it.
1940        let c = Custody::delegated("svc://gateway-rooms", "agent://fizz");
1941        assert!(!c.signer.is_empty());
1942        assert!(!c.on_behalf_of.is_empty());
1943
1944        // And the validator refuses a hand-built custody with no signer.
1945        let payload = serde_json::json!({
1946            "session_id": "ssn_bad",
1947            "actor": "agent://fizz",
1948            "outcome": "completed",
1949            "started_at": "2026-08-10T10:00:00Z",
1950            "closed_at": "2026-08-10T10:30:00Z",
1951            "attestation_class": "self",
1952            "receipt_digest": format!("sha256:{}", "c".repeat(64)),
1953            "custody": { "mode": "delegated", "on_behalf_of": "agent://fizz" }
1954        });
1955        let err = crate::predicates::validate("session.v1", Some(&payload))
1956            .expect_err("a custody block without a signer must be refused before signing");
1957        assert!(
1958            err.to_string().contains("custody.signer"),
1959            "the refusal names the nested field: {err}"
1960        );
1961    }
1962
1963    /// `compose_with_custody(.., None)` must be indistinguishable from
1964    /// `compose` -- otherwise adding the parameter silently changed every
1965    /// existing receipt.
1966    #[test]
1967    fn none_custody_composes_identically() {
1968        let m = SessionManifest::new(
1969            "ssn_x".into(),
1970            "ship://local".into(),
1971            "2026-08-10T10:00:00Z".into(),
1972            1_760_000_000_000,
1973        );
1974        let a = ReceiptComposer::compose(&m, &[], Vec::new());
1975        let b = ReceiptComposer::compose_with_custody(&m, &[], Vec::new(), None);
1976        assert_eq!(
1977            serde_json::to_string(&a).unwrap(),
1978            serde_json::to_string(&b).unwrap()
1979        );
1980    }
1981
1982    #[test]
1983    fn delegated_custody_reaches_the_composed_receipt() {
1984        let m = SessionManifest::new(
1985            "ssn_y".into(),
1986            "agent://fizz".into(),
1987            "2026-08-10T10:00:00Z".into(),
1988            1_760_000_000_000,
1989        );
1990        let r = ReceiptComposer::compose_with_custody(
1991            &m,
1992            &[],
1993            Vec::new(),
1994            Some(Custody::delegated("svc://gateway-rooms", "agent://fizz")),
1995        );
1996        let v = serde_json::to_value(&r).unwrap();
1997        assert_eq!(v["custody"]["signer"], "svc://gateway-rooms");
1998        assert_eq!(v["custody"]["on_behalf_of"], "agent://fizz");
1999    }
2000}