Skip to main content

treeship_core/session/
receipt.rs

1//! Session Receipt composer.
2//!
3//! Builds the canonical Session Receipt JSON from session events,
4//! artifact store, and Merkle tree. The receipt is the composed
5//! package-level artifact that unifies an entire session.
6
7use serde::{Deserialize, Serialize};
8use sha2::{Sha256, Digest};
9
10use crate::merkle::{MerkleTree, InclusionProof};
11
12use super::event::SessionEvent;
13use super::graph::AgentGraph;
14use super::manifest::{
15    HostInfo, LifecycleMode, Participants, SessionManifest, SessionStatus, ToolInfo,
16};
17use super::render::RenderConfig;
18use super::side_effects::SideEffects;
19
20/// Receipt type identifier.
21pub const RECEIPT_TYPE: &str = "treeship/session-receipt/v1";
22
23/// Current receipt schema version. Receipts without this field are treated
24/// as schema "0" and verified under legacy rules (pre-v0.9.0 shape).
25pub const RECEIPT_SCHEMA_VERSION: &str = "1";
26
27// ── Top-level receipt ────────────────────────────────────────────────
28
29/// The complete Session Receipt.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct SessionReceipt {
32    /// Always "treeship/session-receipt/v1".
33    #[serde(rename = "type")]
34    pub type_: String,
35
36    /// Schema version. Absent on pre-v0.9.0 receipts (treated as "0").
37    /// Set to "1" for v0.9.0+ receipts.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub schema_version: Option<String>,
40
41    pub session: SessionSection,
42    pub participants: Participants,
43    pub hosts: Vec<HostInfo>,
44    pub tools: Vec<ToolInfo>,
45    pub agent_graph: AgentGraph,
46    pub timeline: Vec<TimelineEntry>,
47    pub side_effects: SideEffects,
48    pub artifacts: Vec<ArtifactEntry>,
49    pub proofs: ProofsSection,
50    pub merkle: MerkleSection,
51    pub render: RenderConfig,
52    /// Tool usage summary: declared vs actual tools used during the session.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub tool_usage: Option<ToolUsage>,
55}
56
57/// Tool authorization and usage summary for the session.
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct ToolUsage {
60    /// Tools declared as authorized (from declaration.json).
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub declared: Vec<String>,
63    /// Tools actually called during the session with invocation counts.
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub actual: Vec<ToolUsageEntry>,
66    /// Tools called that were NOT in the declared list.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub unauthorized: Vec<String>,
69}
70
71/// A single tool's usage count.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ToolUsageEntry {
74    pub tool_name: String,
75    pub count: u32,
76}
77
78/// Session metadata section of the receipt.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct SessionSection {
81    pub id: String,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub name: Option<String>,
84    pub mode: LifecycleMode,
85    pub started_at: String,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub ended_at: Option<String>,
88    pub status: SessionStatus,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub duration_ms: Option<u64>,
91    /// Ship ID this session ran under, parsed from the manifest actor URI
92    /// (`ship://<ship_id>`). Absent on pre-v0.9.0 receipts or when the actor
93    /// URI was not a ship:// URI (e.g. human://alice for a human-led session).
94    /// Cross-verification uses this to check that a receipt and a presented
95    /// Agent Certificate reference the same ship.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub ship_id: Option<String>,
98    /// Structured narrative for human review. All fields optional.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub narrative: Option<Narrative>,
101    /// Cumulative input tokens across all agents.
102    #[serde(default)]
103    pub total_tokens_in: u64,
104    /// Cumulative output tokens across all agents.
105    #[serde(default)]
106    pub total_tokens_out: u64,
107}
108
109
110/// Structured narrative for the session summary.
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112pub struct Narrative {
113    /// One-line headline: "Verifier refactor completed."
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub headline: Option<String>,
116    /// Multi-sentence summary of what happened.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub summary: Option<String>,
119    /// What should be reviewed before trusting the output.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub review: Option<String>,
122}
123
124/// A single timeline entry.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct TimelineEntry {
127    pub sequence_no: u64,
128    pub timestamp: String,
129    pub event_id: String,
130    pub event_type: String,
131    pub agent_instance_id: String,
132    pub agent_name: String,
133    pub host_id: String,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub summary: Option<String>,
136}
137
138/// An artifact referenced in the session.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct ArtifactEntry {
141    pub artifact_id: String,
142    pub payload_type: String,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub digest: Option<String>,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub signed_at: Option<String>,
147}
148
149/// Proofs section of the receipt.
150#[derive(Debug, Clone, Default, Serialize, Deserialize)]
151pub struct ProofsSection {
152    #[serde(default)]
153    pub signature_count: u32,
154    #[serde(default)]
155    pub signatures_valid: bool,
156    #[serde(default)]
157    pub merkle_root_valid: bool,
158    #[serde(default)]
159    pub inclusion_proofs_count: u32,
160    #[serde(default)]
161    pub zk_proofs_present: bool,
162    /// Count of events.jsonl lines that were skipped during read_all
163    /// because they failed to deserialize. Set by session::close from
164    /// EventLog::read_all_with_stats. Codex adversarial review finding #8:
165    /// without this in-band signal, a receipt sealed after malformed
166    /// events were silently dropped looks complete to a verifier even
167    /// when it isn't. `treeship package verify` surfaces this as a WARN
168    /// when nonzero. Defaults to 0; absent on pre-v0.9.6 receipts so
169    /// they still verify byte-identical.
170    #[serde(default, skip_serializing_if = "is_zero_u32")]
171    pub event_log_skipped: u32,
172    #[serde(default, skip_serializing_if = "is_zero_u32")]
173    pub reconcile_untracked_truncated: u32,
174    #[serde(default, skip_serializing_if = "is_zero_u32")]
175    pub reconcile_untracked_cap: u32,
176    /// AUD-07: the git-diff backstop was unavailable at close even though git
177    /// worked at session start (start_commit_sha was captured). A file could
178    /// have changed via a non-AgentWroteFile channel and the only backstop
179    /// that would have caught it was disabled (`.git` removed, corrupt index,
180    /// PATH-poisoned git), so the "Files changed" ledger may be incomplete.
181    /// `package verify` WARNs on this. Absent on receipts sealed before this
182    /// field so they stay byte-identical.
183    #[serde(default, skip_serializing_if = "is_false")]
184    pub reconcile_degraded: bool,
185}
186
187fn is_zero_u32(n: &u32) -> bool { *n == 0 }
188fn is_false(b: &bool) -> bool { !*b }
189
190/// Merkle section of the receipt.
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct MerkleSection {
193    pub leaf_count: usize,
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub root: Option<String>,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub checkpoint_id: Option<String>,
198    #[serde(default, skip_serializing_if = "Vec::is_empty")]
199    pub inclusion_proofs: Vec<InclusionProofEntry>,
200    /// Merkle format version byte. Drives the leaf/internal hash dispatch
201    /// at verify time. Absent on pre-v0.10.3 receipts — defaults to `1`
202    /// (no domain separation) so v0.10.2 receipts continue to verify.
203    /// New receipts always serialize `2` (RFC 9162 domain separation).
204    #[serde(default = "crate::merkle::tree::default_merkle_version_v1")]
205    pub merkle_version: u8,
206}
207
208impl Default for MerkleSection {
209    fn default() -> Self {
210        // Default newly-constructed sections to v2 — the in-the-wild
211        // "default = v1" behavior only triggers when serde fills the
212        // field for a JSON that omitted it (legacy receipts).
213        Self {
214            leaf_count: 0,
215            root: None,
216            checkpoint_id: None,
217            inclusion_proofs: Vec::new(),
218            merkle_version: crate::merkle::tree::MERKLE_VERSION_V2,
219        }
220    }
221}
222
223/// A Merkle inclusion proof entry.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct InclusionProofEntry {
226    pub artifact_id: String,
227    pub leaf_index: usize,
228    pub proof: InclusionProof,
229}
230
231// ── Composer ─────────────────────────────────────────────────────────
232
233/// Composes a Session Receipt from events and artifacts.
234pub struct ReceiptComposer;
235
236impl ReceiptComposer {
237    /// Compose a receipt from a session manifest, events, and optional artifact entries.
238    pub fn compose(
239        manifest: &SessionManifest,
240        events: &[SessionEvent],
241        artifact_entries: Vec<ArtifactEntry>,
242    ) -> SessionReceipt {
243        // Build agent graph
244        let agent_graph = AgentGraph::from_events(events);
245
246        // Build side effects
247        let side_effects = SideEffects::from_events(events);
248
249        // Build timeline from all events
250        let mut timeline: Vec<TimelineEntry> = events.iter().map(|e| {
251            TimelineEntry {
252                sequence_no: e.sequence_no,
253                timestamp: e.timestamp.clone(),
254                event_id: e.event_id.clone(),
255                event_type: event_type_label(&e.event_type),
256                agent_instance_id: e.agent_instance_id.clone(),
257                agent_name: e.agent_name.clone(),
258                host_id: e.host_id.clone(),
259                summary: event_summary(&e.event_type),
260            }
261        }).collect();
262
263        // Sort by (timestamp, sequence_no, event_id) for determinism
264        timeline.sort_by(|a, b| {
265            a.timestamp.cmp(&b.timestamp)
266                .then(a.sequence_no.cmp(&b.sequence_no))
267                .then(a.event_id.cmp(&b.event_id))
268        });
269
270        // Compute participants from graph
271        let participants = compute_participants(&agent_graph, manifest);
272
273        // Compute hosts and tools from events
274        let hosts = compute_hosts(events, &manifest.hosts);
275        let tools = compute_tools(events, &manifest.tools);
276
277        // Compute duration from the session close event if present
278        let duration_ms = events.iter().find_map(|e| {
279            if let super::event::EventType::SessionClosed { duration_ms, .. } = &e.event_type {
280                *duration_ms
281            } else {
282                None
283            }
284        });
285
286        // Build Merkle tree from artifact IDs
287        let (merkle_section, merkle_tree) = build_merkle(&artifact_entries);
288
289        // Proofs section. zk_proofs_present defaults to false here;
290        // the CLI caller sets it to true after compose if proof files
291        // exist in the session directory.
292        let proofs = ProofsSection {
293            signature_count: artifact_entries.len() as u32,
294            // AUD-01: compose does NOT run a signature-verification pass over
295            // the artifacts, so this must not claim signatures were verified.
296            // A `true` here was a self-asserted "valid" flag baked into the
297            // signed receipt that a consumer could mistake for an independent
298            // verification result. It stays false unless a real verify pass
299            // sets it.
300            signatures_valid: false,
301            merkle_root_valid: merkle_tree.is_some(),
302            inclusion_proofs_count: merkle_section.inclusion_proofs.len() as u32,
303            zk_proofs_present: false,
304            event_log_skipped: 0, // Set by caller after compose (Codex #8)
305            reconcile_untracked_truncated: 0,
306            reconcile_untracked_cap: 0,
307            reconcile_degraded: false, // Set by caller after compose (AUD-07)
308        };
309
310        // Compute cost/token totals from agent graph
311        // Cost is deliberately not aggregated. See event.rs comment.
312        let total_tokens_in: u64 = agent_graph.nodes.iter().map(|n| n.tokens_in).sum();
313        let total_tokens_out: u64 = agent_graph.nodes.iter().map(|n| n.tokens_out).sum();
314
315        // Session section
316        let session = SessionSection {
317            id: manifest.session_id.clone(),
318            name: manifest.name.clone(),
319            mode: manifest.mode.clone(),
320            started_at: manifest.started_at.clone(),
321            ended_at: manifest.closed_at.clone(),
322            status: manifest.status.clone(),
323            duration_ms,
324            ship_id: parse_ship_id_from_actor(&manifest.actor),
325            narrative: manifest.summary.as_ref().map(|s| Narrative {
326                headline: manifest.name.clone(),
327                summary: Some(s.clone()),
328                review: None,
329            }),
330            total_tokens_in,
331            total_tokens_out,
332        };
333
334        // Render config
335        let render = RenderConfig {
336            title: manifest.name.clone(),
337            theme: None,
338            sections: RenderConfig::default_sections(),
339            generate_preview: true,
340        };
341
342        // Derive tool usage from side effects + manifest authorized_tools
343        let tool_usage = derive_tool_usage(&side_effects, &manifest.authorized_tools);
344
345        SessionReceipt {
346            type_: RECEIPT_TYPE.into(),
347            schema_version: Some(RECEIPT_SCHEMA_VERSION.into()),
348            session,
349            participants,
350            hosts,
351            tools,
352            agent_graph,
353            timeline,
354            side_effects,
355            artifacts: artifact_entries,
356            proofs,
357            merkle: merkle_section,
358            render,
359            tool_usage,
360        }
361    }
362
363    /// Produce deterministic canonical JSON bytes from a receipt.
364    ///
365    /// Uses serde's field-declaration-order serialization for determinism.
366    /// The resulting bytes are suitable for hashing.
367    pub fn to_canonical_json(receipt: &SessionReceipt) -> Result<Vec<u8>, serde_json::Error> {
368        serde_json::to_vec(receipt)
369    }
370
371    /// Compute SHA-256 digest of the canonical receipt JSON.
372    pub fn digest(receipt: &SessionReceipt) -> Result<String, serde_json::Error> {
373        let bytes = Self::to_canonical_json(receipt)?;
374        let hash = Sha256::digest(&bytes);
375        Ok(format!("sha256:{}", hex::encode(hash)))
376    }
377}
378
379// ── Helpers ──────────────────────────────────────────────────────────
380
381fn compute_participants(graph: &AgentGraph, manifest: &SessionManifest) -> Participants {
382    use std::collections::BTreeSet;
383
384    let mut tool_runtimes: BTreeSet<String> = BTreeSet::new();
385    // Count unique agents
386    let total_agents = graph.nodes.len() as u32;
387    let spawned_subagents = graph.spawn_count();
388    let handoffs = graph.handoff_count();
389    let max_depth = graph.max_depth();
390    let host_ids = graph.host_ids();
391
392    // Collect tool runtimes from events in manifest
393    for tool in &manifest.tools {
394        if let Some(ref rt) = tool.tool_runtime_id {
395            tool_runtimes.insert(rt.clone());
396        }
397    }
398
399    // Find root agent (depth 0, first started)
400    let root = graph.nodes.iter()
401        .filter(|n| n.depth == 0)
402        .min_by_key(|n| n.started_at.as_deref().unwrap_or(""))
403        .map(|n| n.agent_instance_id.clone());
404
405    // Find final output agent (last completed at max depth or last completed overall)
406    let final_output = graph.nodes.iter()
407        .filter(|n| n.completed_at.is_some())
408        .max_by_key(|n| n.completed_at.as_deref().unwrap_or(""))
409        .map(|n| n.agent_instance_id.clone());
410
411    Participants {
412        root_agent_instance_id: root.or(manifest.participants.root_agent_instance_id.clone()),
413        final_output_agent_instance_id: final_output.or(manifest.participants.final_output_agent_instance_id.clone()),
414        total_agents,
415        spawned_subagents,
416        handoffs,
417        max_depth,
418        hosts: host_ids.len() as u32,
419        tool_runtimes: tool_runtimes.len() as u32,
420    }
421}
422
423fn compute_hosts(events: &[SessionEvent], manifest_hosts: &[HostInfo]) -> Vec<HostInfo> {
424    use std::collections::BTreeMap;
425
426    let mut hosts: BTreeMap<String, HostInfo> = BTreeMap::new();
427
428    // Seed from manifest
429    for h in manifest_hosts {
430        hosts.insert(h.host_id.clone(), h.clone());
431    }
432
433    // Discover from events
434    for e in events {
435        hosts.entry(e.host_id.clone()).or_insert_with(|| HostInfo {
436            host_id: e.host_id.clone(),
437            hostname: None,
438            os: None,
439            arch: None,
440        });
441    }
442
443    hosts.into_values().collect()
444}
445
446fn compute_tools(events: &[SessionEvent], manifest_tools: &[ToolInfo]) -> Vec<ToolInfo> {
447    use std::collections::BTreeMap;
448
449    let mut tools: BTreeMap<String, ToolInfo> = BTreeMap::new();
450
451    // Seed from manifest
452    for t in manifest_tools {
453        tools.insert(t.tool_id.clone(), t.clone());
454    }
455
456    // Count tool invocations from events
457    for e in events {
458        if let super::event::EventType::AgentCalledTool { ref tool_name, .. } = e.event_type {
459            let entry = tools.entry(tool_name.clone()).or_insert_with(|| ToolInfo {
460                tool_id: tool_name.clone(),
461                tool_name: tool_name.clone(),
462                tool_runtime_id: e.tool_runtime_id.clone(),
463                invocation_count: 0,
464            });
465            entry.invocation_count += 1;
466        }
467    }
468
469    tools.into_values().collect()
470}
471
472fn build_merkle(artifacts: &[ArtifactEntry]) -> (MerkleSection, Option<MerkleTree>) {
473    if artifacts.is_empty() {
474        return (MerkleSection::default(), None);
475    }
476
477    let mut tree = MerkleTree::new();
478    for art in artifacts {
479        tree.append(&art.artifact_id);
480    }
481
482    let root = tree.root().map(|r| format!("mroot_{}", hex::encode(r)));
483
484    // Build inclusion proofs for each artifact
485    let inclusion_proofs: Vec<InclusionProofEntry> = artifacts.iter().enumerate()
486        .filter_map(|(i, art)| {
487            tree.inclusion_proof(i).map(|proof| InclusionProofEntry {
488                artifact_id: art.artifact_id.clone(),
489                leaf_index: i,
490                proof,
491            })
492        })
493        .collect();
494
495    let section = MerkleSection {
496        leaf_count: artifacts.len(),
497        root,
498        checkpoint_id: None,
499        inclusion_proofs,
500        merkle_version: tree.version(),
501    };
502
503    (section, Some(tree))
504}
505
506/// Extract the ship_id from an actor URI of the form `ship://<id>`.
507/// Returns None for other URI schemes (human://, agent://) or malformed values.
508pub fn parse_ship_id_from_actor(actor: &str) -> Option<String> {
509    let rest = actor.strip_prefix("ship://")?;
510    // Strip any trailing path segment so `ship://ship_abc/foo` -> `ship_abc`.
511    let id = rest.split('/').next().unwrap_or(rest);
512    if id.is_empty() { None } else { Some(id.to_string()) }
513}
514
515/// Extract a human-readable label from an EventType.
516/// Derive tool usage from side effects and the declared authorized tools list.
517///
518/// Bug Codex caught in adversarial review: previously this function counted
519/// only `side_effects.tool_invocations` (built from `EventType::AgentCalledTool`).
520/// But Claude Code's PostToolUse hook emits SPECIALIZED events for built-in
521/// tools (`agent.wrote_file` for Write/Edit, `agent.completed_process` for
522/// Bash, `agent.read_file` for Read, etc) -- those events never landed in
523/// `tool_invocations`, so a certificate that omitted "Bash" or "Write"
524/// passed cross-verification cleanly even when the agent ran them.
525///
526/// The fix: also count side effects from specialized event types under
527/// canonical tool names that match what an operator would declare in
528/// `bounded_actions`. Naming follows Claude Code conventions (Read, Write,
529/// Bash, WebFetch) since those are the tools users actually declare. A
530/// cert that uses an alternate naming scheme (e.g. `files.write`) needs
531/// to declare both for now -- a future TODO is canonical mapping at the
532/// cert layer.
533/// Side-effect canonical mapping for tool authorization.
534///
535/// Each entry maps a side-effect bucket to a canonical tool name AND a
536/// list of accepted aliases. The canonical name is what gets recorded
537/// in `tool_usage.actual`. Any alias from the authorized_tools list
538/// counts as authorization for the canonical name.
539///
540/// Codex round-2 caught two bugs in the round-1 fix:
541///
542/// 1. The round-1 mapping used Claude-Code TitleCase ("Read", "Write",
543///    "Bash") but the existing CLI -- `treeship declare --tools
544///    read_file,write_file,bash` per declare.rs:80 and `treeship agent
545///    register --tools read_file,write_file,bash` per main.rs:226 --
546///    teaches users lowercase snake_case names. So a cert that follows
547///    the documented convention got every actual tool flagged as
548///    unauthorized. Aliases close that gap: declarations in either
549///    convention authorize the same canonical entry.
550///
551/// 2. The round-1 logic counted side effects regardless of provenance.
552///    `git-reconcile` synthetic writes (the backstop layer) registered
553///    as tool use even though no actual tool was directly attributed
554///    for them. A build script that touched a file made the receipt
555///    say "Write tool was used", and the cert had to authorize Write
556///    or fail cross-verify -- even though the agent never invoked any
557///    Write tool. Below, only direct-attribution sources (`hook`,
558///    `mcp`, `shell-wrap`, `session-event-cli`, and untagged legacy
559///    events) count toward tool usage. Backstop sources (`git-reconcile`,
560///    `daemon-atime`) surface in the receipt's "Files changed" section
561///    so the reader sees the change, but they do NOT claim that an
562///    agent tool was the proximate cause. See source_attributes_a_tool
563///    below for the authoritative allow list.
564const TOOL_ALIASES: &[(&str, &[&str])] = &[
565    // Canonical first; rest are accepted aliases.
566    ("read_file",  &["read_file", "Read"]),
567    ("write_file", &["write_file", "Write", "Edit", "MultiEdit", "NotebookEdit", "edit_file"]),
568    ("bash",       &["bash", "Bash", "shell"]),
569    ("web_fetch",  &["web_fetch", "WebFetch", "webfetch"]),
570];
571
572/// Returns true iff `source` represents a direct tool attribution that
573/// should count toward `tool_usage.actual`.
574///
575/// Direct attribution sources -- a real tool fired and the channel
576/// captured it:
577///   - `hook`              integration hook saw the tool fire
578///   - `mcp`               promoted from MCP-bridge agent.called_tool
579///   - `shell-wrap`        `treeship wrap` captured a shell command
580///   - `session-event-cli` `treeship session event` from a hook script.
581///                         The Claude Code plugin's PostToolUse hook
582///                         calls `treeship session event --type
583///                         agent.wrote_file --file X`, and the CLI
584///                         tags those as "session-event-cli" -- so
585///                         excluding this label would make every
586///                         claude-code-plugin event invisible to
587///                         cross-verify.
588///   - None                legacy untagged event (back-compat)
589///
590/// Backstop / inference sources -- a file changed but no tool was
591/// directly attributed. Surface in the receipt's "Files changed"
592/// section so the reader sees the change but they must NOT inflate
593/// tool_usage:
594///   - `git-reconcile`     git diff at session close
595///   - `daemon-atime`      atime-based file detection
596fn source_attributes_a_tool(source: Option<&str>) -> bool {
597    matches!(
598        source,
599        None
600            | Some("hook")
601            | Some("mcp")
602            | Some("shell-wrap")
603            | Some("session-event-cli"),
604    )
605}
606
607/// Counts side effects by canonical tool name, filtering out
608/// non-attribution sources (git-reconcile, daemon-atime).
609fn count_attributed<'a, F>(
610    items: usize,
611    source_at: F,
612    canonical: &str,
613    counts: &mut std::collections::BTreeMap<String, u32>,
614)
615where
616    F: Fn(usize) -> Option<&'a str>,
617{
618    let n: u32 = (0..items)
619        .filter(|i| source_attributes_a_tool(source_at(*i)))
620        .count() as u32;
621    if n > 0 {
622        *counts.entry(canonical.to_string()).or_insert(0) += n;
623    }
624}
625
626fn derive_tool_usage(
627    side_effects: &SideEffects,
628    authorized_tools: &[String],
629) -> Option<ToolUsage> {
630    use std::collections::BTreeMap;
631
632    let total_specialized = side_effects.files_read.len()
633        + side_effects.files_written.len()
634        + side_effects.processes.len()
635        + side_effects.network_connections.len();
636
637    if side_effects.tool_invocations.is_empty()
638        && total_specialized == 0
639        && authorized_tools.is_empty()
640    {
641        return None;
642    }
643
644    let mut counts: BTreeMap<String, u32> = BTreeMap::new();
645
646    // Generic agent.called_tool events use the tool's actual name.
647    // The MCP bridge writes meta.source = "mcp-bridge" (which is not
648    // in source_attributes_a_tool's allow list) but tool_invocations
649    // come ONLY from agent.called_tool, which is direct attribution
650    // by definition -- so count all of them, no source filter applies
651    // here. (The bridge tool name is the source.)
652    for inv in &side_effects.tool_invocations {
653        *counts.entry(inv.tool_name.clone()).or_insert(0) += 1;
654    }
655
656    // Specialized side effects, source-filtered: only direct
657    // attribution (hook / mcp / shell-wrap / untagged-legacy) counts.
658    // git-reconcile and friends surface in the "Files changed" section
659    // for the reader but do NOT inflate tool_usage.
660    let fr = &side_effects.files_read;
661    count_attributed(
662        fr.len(),
663        |i| fr[i].source.as_deref(),
664        "read_file",
665        &mut counts,
666    );
667    let fw = &side_effects.files_written;
668    count_attributed(
669        fw.len(),
670        |i| fw[i].source.as_deref(),
671        "write_file",
672        &mut counts,
673    );
674    let pr = &side_effects.processes;
675    count_attributed(
676        pr.len(),
677        |i| pr[i].source.as_deref(),
678        "bash",
679        &mut counts,
680    );
681    // network_connections has no source field today; treat all as
682    // attributed (this matches the round-1 behavior since there's no
683    // backstop layer producing network entries).
684    if !side_effects.network_connections.is_empty() {
685        *counts.entry("web_fetch".to_string()).or_insert(0) +=
686            side_effects.network_connections.len() as u32;
687    }
688
689    let actual: Vec<ToolUsageEntry> = counts.iter()
690        .map(|(name, &count)| ToolUsageEntry { tool_name: name.clone(), count })
691        .collect();
692
693    // Authorization check uses alias resolution: an actual tool is
694    // unauthorized only if NONE of its aliases are in the declared
695    // list. So a declaration of "read_file" authorizes both "Read"
696    // (Claude convention) and "read_file" (CLI convention) when they
697    // produce the canonical "read_file" actual entry.
698    let unauthorized = if authorized_tools.is_empty() {
699        Vec::new()
700    } else {
701        let declared_set: std::collections::BTreeSet<&str> = authorized_tools.iter()
702            .map(|s| s.as_str())
703            .collect();
704        counts.keys()
705            .filter(|actual_name| !is_authorized(actual_name, &declared_set))
706            .cloned()
707            .collect()
708    };
709
710    Some(ToolUsage {
711        declared: authorized_tools.to_vec(),
712        actual,
713        unauthorized,
714    })
715}
716
717/// Returns true if `actual_name` (or any of its declared aliases) is
718/// in the declared set. Aliases mean a cert can use either Claude
719/// convention or snake_case CLI convention and still authorize the
720/// same canonical bucket.
721fn is_authorized(actual_name: &str, declared_set: &std::collections::BTreeSet<&str>) -> bool {
722    // Direct hit: the declared set names this tool exactly.
723    if declared_set.contains(actual_name) {
724        return true;
725    }
726    // Alias hit: walk the canonical mapping and see if any alias of
727    // the canonical bucket the actual_name belongs to is in declared.
728    for (canonical, aliases) in TOOL_ALIASES {
729        if *canonical == actual_name || aliases.contains(&actual_name) {
730            for alias in *aliases {
731                if declared_set.contains(*alias) {
732                    return true;
733                }
734            }
735            return false;
736        }
737    }
738    false
739}
740
741fn event_type_label(et: &super::event::EventType) -> String {
742    use super::event::EventType::*;
743    match et {
744        SessionStarted => "session.started",
745        SessionClosed { .. } => "session.closed",
746        AgentStarted { .. } => "agent.started",
747        AgentSpawned { .. } => "agent.spawned",
748        AgentHandoff { .. } => "agent.handoff",
749        AgentCollaborated { .. } => "agent.collaborated",
750        AgentReturned { .. } => "agent.returned",
751        AgentCompleted { .. } => "agent.completed",
752        AgentFailed { .. } => "agent.failed",
753        AgentCalledTool { .. } => "agent.called_tool",
754        AgentReadFile { .. } => "agent.read_file",
755        AgentWroteFile { .. } => "agent.wrote_file",
756        AgentOpenedPort { .. } => "agent.opened_port",
757        AgentConnectedNetwork { .. } => "agent.connected_network",
758        AgentStartedProcess { .. } => "agent.started_process",
759        AgentCompletedProcess { .. } => "agent.completed_process",
760        AgentDecision { .. } => "agent.decision",
761    }.into()
762}
763
764/// Optional human-readable summary from an EventType.
765fn event_summary(et: &super::event::EventType) -> Option<String> {
766    use super::event::EventType::*;
767    match et {
768        SessionStarted => Some("Session started".into()),
769        SessionClosed { summary, .. } => summary.clone().or(Some("Session closed".into())),
770        AgentSpawned { reason, .. } => reason.clone(),
771        AgentHandoff { from_agent_instance_id, to_agent_instance_id, .. } => {
772            Some(format!("{from_agent_instance_id} -> {to_agent_instance_id}"))
773        }
774        AgentCalledTool { tool_name, .. } => Some(format!("Called {tool_name}")),
775        AgentReadFile { file_path, .. } => Some(format!("Read {file_path}")),
776        AgentWroteFile { file_path, .. } => Some(format!("Wrote {file_path}")),
777        AgentOpenedPort { port, .. } => Some(format!("Opened port {port}")),
778        AgentConnectedNetwork { destination, .. } => Some(format!("Connected to {destination}")),
779        AgentStartedProcess { process_name, .. } => Some(format!("Started {process_name}")),
780        AgentCompletedProcess { process_name, exit_code, .. } => {
781            Some(format!("Completed {process_name} (exit {})", exit_code.unwrap_or(-1)))
782        }
783        AgentCompleted { termination_reason } => termination_reason.clone().or(Some("Agent completed".into())),
784        AgentFailed { reason } => reason.clone().or(Some("Agent failed".into())),
785        AgentDecision { model, summary, provider, .. } => {
786            let mut parts = Vec::new();
787            if let Some(s) = summary { parts.push(s.clone()); }
788            if let Some(m) = model { parts.push(format!("model: {m}")); }
789            if let Some(p) = provider { parts.push(format!("via {p}")); }
790            if parts.is_empty() { Some("LLM decision".into()) } else { Some(parts.join(" | ")) }
791        }
792        _ => None,
793    }
794}
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799    use crate::session::event::*;
800
801    fn make_manifest() -> SessionManifest {
802        SessionManifest::new(
803            "ssn_001".into(),
804            "agent://test".into(),
805            "2026-04-05T08:00:00Z".into(),
806            1743843600000,
807        )
808    }
809
810    /// Module-level event constructor so the tool-authorization regression
811    /// tests below can reuse it without each redefining the closure.
812    fn mk(seq: u64, inst: &str, et: EventType) -> SessionEvent {
813        SessionEvent {
814            session_id: "ssn_001".into(),
815            event_id: format!("evt_{:016x}", seq),
816            timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
817            sequence_no: seq,
818            trace_id: "trace_1".into(),
819            span_id: format!("span_{seq}"),
820            parent_span_id: None,
821            agent_id: format!("agent://{inst}"),
822            agent_instance_id: inst.into(),
823            agent_name: inst.into(),
824            agent_role: None,
825            host_id: "host_1".into(),
826            tool_runtime_id: None,
827            event_type: et,
828            artifact_ref: None,
829            meta: None,
830        }
831    }
832
833    fn make_events() -> Vec<SessionEvent> {
834        vec![
835            mk(0, "root", EventType::SessionStarted),
836            mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
837            mk(2, "worker", EventType::AgentSpawned { spawned_by_agent_instance_id: "root".into(), reason: Some("review".into()) }),
838            mk(3, "worker", EventType::AgentCalledTool { tool_name: "read_file".into(), tool_input_digest: None, tool_output_digest: None, duration_ms: Some(5) }),
839            mk(4, "worker", EventType::AgentWroteFile { file_path: "src/fix.rs".into(), digest: None, operation: None, additions: None, deletions: None }),
840            mk(5, "worker", EventType::AgentCompleted { termination_reason: None }),
841            mk(6, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(360000) }),
842        ]
843    }
844
845    #[test]
846    fn compose_receipt() {
847        let manifest = make_manifest();
848        let events = make_events();
849        let artifacts = vec![
850            ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
851            ArtifactEntry { artifact_id: "art_002".into(), payload_type: "action".into(), digest: None, signed_at: None },
852        ];
853
854        let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
855
856        assert_eq!(receipt.type_, RECEIPT_TYPE);
857        assert_eq!(receipt.session.id, "ssn_001");
858        assert_eq!(receipt.timeline.len(), 7);
859        assert_eq!(receipt.agent_graph.nodes.len(), 2); // root + worker
860        assert_eq!(receipt.side_effects.files_written.len(), 1);
861        assert_eq!(receipt.merkle.leaf_count, 2);
862        assert!(receipt.merkle.root.is_some());
863    }
864
865    #[test]
866    fn new_receipts_carry_schema_version() {
867        let manifest = make_manifest();
868        let events = make_events();
869        let artifacts = vec![
870            ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
871        ];
872        let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
873        assert_eq!(receipt.schema_version.as_deref(), Some(RECEIPT_SCHEMA_VERSION));
874        // And it shows up in canonical JSON.
875        let json = String::from_utf8(ReceiptComposer::to_canonical_json(&receipt).unwrap()).unwrap();
876        assert!(json.contains(r#""schema_version":"1""#), "missing schema_version: {json}");
877    }
878
879    #[test]
880    fn legacy_receipt_without_schema_version_round_trips_byte_identical() {
881        // Simulate a pre-v0.9.0 receipt by composing one and stripping the
882        // schema_version field. Re-serializing must produce byte-identical
883        // output so the package-level determinism check keeps passing for
884        // old receipts that nobody can re-sign.
885        let manifest = make_manifest();
886        let events = make_events();
887        let artifacts = vec![
888            ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
889        ];
890        let mut receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
891        receipt.schema_version = None; // mimic a legacy receipt
892
893        let original = ReceiptComposer::to_canonical_json(&receipt).unwrap();
894        // Verify the field is omitted, not serialized as null.
895        let original_str = std::str::from_utf8(&original).unwrap();
896        assert!(!original_str.contains("schema_version"),
897            "schema_version must be skipped when None");
898
899        let parsed: SessionReceipt = serde_json::from_slice(&original).unwrap();
900        assert!(parsed.schema_version.is_none(), "legacy receipts must parse with schema_version=None");
901
902        let reserialized = ReceiptComposer::to_canonical_json(&parsed).unwrap();
903        assert_eq!(original, reserialized,
904            "legacy receipt must round-trip byte-identical so package determinism check passes");
905    }
906
907    #[test]
908    fn canonical_json_is_deterministic() {
909        let manifest = make_manifest();
910        let events = make_events();
911        let artifacts = vec![
912            ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
913        ];
914
915        let r1 = ReceiptComposer::compose(&manifest, &events, artifacts.clone());
916        let r2 = ReceiptComposer::compose(&manifest, &events, artifacts);
917
918        let j1 = ReceiptComposer::to_canonical_json(&r1).unwrap();
919        let j2 = ReceiptComposer::to_canonical_json(&r2).unwrap();
920        assert_eq!(j1, j2);
921
922        let d1 = ReceiptComposer::digest(&r1).unwrap();
923        let d2 = ReceiptComposer::digest(&r2).unwrap();
924        assert_eq!(d1, d2);
925    }
926
927    // ── Tool authorization regression tests (Codex finding #1) ──
928    //
929    // Specialized event types (agent.wrote_file, agent.completed_process,
930    // agent.read_file) must contribute to tool_usage.actual so that a
931    // certificate's bounded_actions list can correctly flag unauthorized
932    // built-in tool usage. Before this fix, only agent.called_tool fed
933    // tool_usage.actual, so a cert that omitted "Bash" still passed even
934    // when the agent ran Bash via Claude Code's built-in.
935
936    fn manifest_with_authorized(tools: Vec<&str>) -> SessionManifest {
937        let mut m = make_manifest();
938        m.authorized_tools = tools.into_iter().map(String::from).collect();
939        m
940    }
941
942    #[test]
943    fn cert_omitting_bash_flags_unauthorized_when_session_runs_bash() {
944        // Cert uses CLI-documented snake_case names (declare.rs:80,
945        // main.rs:226). Round-2 fix: canonical actual is "bash" not
946        // "Bash"; round-1 was flagging mismatches the wrong way.
947        let manifest = manifest_with_authorized(vec!["read_file", "write_file"]); // NO bash
948        let events = vec![
949            mk(0, "root", EventType::SessionStarted),
950            mk(1, "agent", EventType::AgentCompletedProcess {
951                process_name: "rm -rf /".into(),
952                exit_code: Some(0),
953                duration_ms: Some(50),
954                command: Some("rm -rf /".into()),
955            }),
956            mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
957        ];
958        let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
959        let tu = receipt.tool_usage.expect("tool_usage must be populated");
960        assert!(
961            tu.unauthorized.iter().any(|t| t == "bash"),
962            "bash must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
963            tu.unauthorized, tu.actual,
964        );
965    }
966
967    #[test]
968    fn cert_omitting_write_flags_unauthorized_when_session_writes_file() {
969        let manifest = manifest_with_authorized(vec!["read_file", "bash"]); // NO write_file
970        let events = vec![
971            mk(0, "root", EventType::SessionStarted),
972            mk(1, "agent", EventType::AgentWroteFile {
973                file_path: "src/secret.rs".into(),
974                digest: None, operation: Some("modified".into()),
975                additions: Some(10), deletions: Some(0),
976            }),
977            mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
978        ];
979        let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
980        let tu = receipt.tool_usage.expect("tool_usage must be populated");
981        assert!(
982            tu.unauthorized.iter().any(|t| t == "write_file"),
983            "write_file must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
984            tu.unauthorized, tu.actual,
985        );
986    }
987
988    #[test]
989    fn cert_includes_read_write_bash_passes_clean_when_all_used() {
990        let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]);
991        let events = vec![
992            mk(0, "root", EventType::SessionStarted),
993            mk(1, "agent", EventType::AgentReadFile { file_path: "package.json".into(), digest: None }),
994            mk(2, "agent", EventType::AgentWroteFile {
995                file_path: "src/lib.rs".into(),
996                digest: None, operation: Some("modified".into()),
997                additions: Some(5), deletions: Some(2),
998            }),
999            mk(3, "agent", EventType::AgentCompletedProcess {
1000                process_name: "bun test".into(),
1001                exit_code: Some(0), duration_ms: Some(2000),
1002                command: Some("bun test".into()),
1003            }),
1004            mk(4, "root", EventType::SessionClosed { summary: None, duration_ms: Some(5000) }),
1005        ];
1006        let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1007        let tu = receipt.tool_usage.expect("tool_usage must be populated");
1008        assert!(
1009            tu.unauthorized.is_empty(),
1010            "all tools declared in cert should pass clean; got unauthorized={:?}",
1011            tu.unauthorized,
1012        );
1013        // The actual list uses canonical lowercase names that match what
1014        // `treeship declare --tools` and `treeship agent register --tools`
1015        // teach (declare.rs:80, main.rs:226).
1016        let actual_names: std::collections::BTreeSet<String> =
1017            tu.actual.iter().map(|e| e.tool_name.clone()).collect();
1018        assert!(actual_names.contains("read_file"));
1019        assert!(actual_names.contains("write_file"));
1020        assert!(actual_names.contains("bash"));
1021    }
1022
1023    #[test]
1024    fn webfetch_unauthorized_flagged_when_cert_omits_it() {
1025        let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]); // NO web_fetch
1026        let events = vec![
1027            mk(0, "root", EventType::SessionStarted),
1028            mk(1, "agent", EventType::AgentConnectedNetwork {
1029                destination: "evil.example.com".into(),
1030                port: Some(443),
1031            }),
1032            mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1033        ];
1034        let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1035        let tu = receipt.tool_usage.expect("tool_usage must be populated");
1036        assert!(
1037            tu.unauthorized.iter().any(|t| t == "web_fetch"),
1038            "web_fetch must be flagged as unauthorized when cert omits it; got unauthorized={:?}",
1039            tu.unauthorized,
1040        );
1041    }
1042
1043    // ── Round-2 fix tests: alias matching + source filtering ──
1044
1045    fn evt_with_source(event_type: EventType, source: &str) -> SessionEvent {
1046        let mut e = mk(99, "agent", event_type);
1047        e.meta = Some(serde_json::json!({"source": source}));
1048        e
1049    }
1050
1051    #[test]
1052    fn titlecase_cert_authorizes_canonical_snake_actuals_via_alias() {
1053        // Operator declares Claude convention. Aliases map "Read" to
1054        // canonical "read_file", "Write" to "write_file", etc.
1055        let manifest = manifest_with_authorized(vec!["Read", "Write", "Bash"]);
1056        let events = vec![
1057            mk(0, "root", EventType::SessionStarted),
1058            mk(1, "agent", EventType::AgentReadFile { file_path: "x".into(), digest: None }),
1059            mk(2, "agent", EventType::AgentWroteFile {
1060                file_path: "y".into(),
1061                digest: None, operation: None, additions: None, deletions: None,
1062            }),
1063            mk(3, "agent", EventType::AgentCompletedProcess {
1064                process_name: "z".into(),
1065                exit_code: Some(0), duration_ms: Some(1), command: None,
1066            }),
1067            mk(4, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1068        ];
1069        let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1070        assert!(
1071            tu.unauthorized.is_empty(),
1072            "TitleCase declarations must authorize canonical snake_case actuals via aliases; \
1073             got unauthorized={:?}",
1074            tu.unauthorized,
1075        );
1076    }
1077
1078    #[test]
1079    fn edit_alias_authorizes_specialized_wrote_file() {
1080        // Operator declares "Edit" specifically. post-tool-use.sh
1081        // emits agent.wrote_file for Edit/MultiEdit alike, so the
1082        // canonical actual is "write_file". Edit is in the write_file
1083        // alias list, so the cert authorizes.
1084        let manifest = manifest_with_authorized(vec!["Edit"]);
1085        let events = vec![
1086            mk(0, "root", EventType::SessionStarted),
1087            mk(1, "agent", EventType::AgentWroteFile {
1088                file_path: "x".into(),
1089                digest: None, operation: None, additions: None, deletions: None,
1090            }),
1091            mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1092        ];
1093        let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1094        assert!(tu.unauthorized.is_empty(), "Edit alias must authorize write_file");
1095    }
1096
1097    #[test]
1098    fn git_reconcile_writes_dont_count_toward_tool_usage() {
1099        // Backstop evidence -- not direct tool attribution.
1100        // A git-reconciled change must NOT make the cert require
1101        // write_file authorization, because no Write tool was invoked.
1102        let manifest = manifest_with_authorized(vec!["read_file"]);
1103        let events = vec![
1104            mk(0, "root", EventType::SessionStarted),
1105            evt_with_source(
1106                EventType::AgentWroteFile {
1107                    file_path: "CHANGELOG.md".into(),
1108                    digest: None, operation: Some("modified".into()),
1109                    additions: Some(7), deletions: Some(2),
1110                },
1111                "git-reconcile",
1112            ),
1113            mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1114        ];
1115        let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1116        assert!(
1117            !tu.unauthorized.iter().any(|t| t == "write_file"),
1118            "git-reconcile entries must NOT count toward tool_usage; \
1119             got unauthorized={:?}, actual={:?}",
1120            tu.unauthorized, tu.actual,
1121        );
1122        let actual_names: std::collections::BTreeSet<String> =
1123            tu.actual.iter().map(|e| e.tool_name.clone()).collect();
1124        assert!(!actual_names.contains("write_file"),
1125            "actual must not include backstop-only writes");
1126    }
1127
1128    // session-event-cli is a direct-attribution source -- the standard
1129    // label the CLI stamps on events emitted by claude-code-plugin's
1130    // PostToolUse hook. So it counts toward tool_usage.actual just like
1131    // hook/mcp/shell-wrap do. The end-to-end test for this lives in
1132    // the targeted acceptance suite (T1) rather than as a unit test
1133    // here, because it requires the full event-emission + receipt-
1134    // composition pipeline running through `treeship session event`.
1135
1136    #[test]
1137    fn hook_emitted_writes_still_count_toward_tool_usage() {
1138        // Positive case: regular hook-emitted write IS direct attribution.
1139        let manifest = manifest_with_authorized(vec!["read_file"]); // NO write_file
1140        let events = vec![
1141            mk(0, "root", EventType::SessionStarted),
1142            evt_with_source(
1143                EventType::AgentWroteFile {
1144                    file_path: "src/x.rs".into(),
1145                    digest: None, operation: None, additions: None, deletions: None,
1146                },
1147                "hook",
1148            ),
1149            mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1150        ];
1151        let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1152        assert!(
1153            tu.unauthorized.iter().any(|t| t == "write_file"),
1154            "hook-emitted writes MUST count toward tool_usage; got unauthorized={:?}",
1155            tu.unauthorized,
1156        );
1157    }
1158
1159    #[test]
1160    fn legacy_untagged_writes_count_for_back_compat() {
1161        // Pre-v0.9.6 events have no source tag. Treat as attributed
1162        // (back-compat: receipts produced before source labeling existed).
1163        let manifest = manifest_with_authorized(vec!["read_file"]); // NO write_file
1164        let events = vec![
1165            mk(0, "root", EventType::SessionStarted),
1166            mk(1, "agent", EventType::AgentWroteFile {
1167                file_path: "x".into(),
1168                digest: None, operation: None, additions: None, deletions: None,
1169            }),
1170            mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1171        ];
1172        let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1173        assert!(
1174            tu.unauthorized.iter().any(|t| t == "write_file"),
1175            "legacy untagged writes must count for back-compat",
1176        );
1177    }
1178}