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