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