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