Skip to main content

capsule_runtime/
trace.rs

1use std::{
2    path::Path,
3    time::{Instant, SystemTime, UNIX_EPOCH},
4};
5
6use murmur_artifact::{ContainmentClass, TraceCapture};
7use serde::Serialize;
8use serde_json::Value;
9use tokio::{
10    fs::{File, OpenOptions},
11    io::{AsyncWriteExt, BufWriter},
12};
13
14use crate::{
15    agent::DriverUsage,
16    containment::ScopeReport,
17    inference_credential::CredentialChange,
18    lanes::TaskLane,
19    origin::{TaskProvenance, TrustClass},
20    trace_blobs::BlobStore,
21};
22
23pub(crate) struct TraceWriter {
24    writer: BufWriter<File>,
25    session_id: String,
26    capsule_name: String,
27    capsule_version: String,
28    model: String,
29    capabilities: Vec<String>,
30    /// The session's complete effective grant set, computed once at stage time. Written whole to
31    /// `session_start` as `effective_grants`, and also the source of that event's
32    /// `containment_declared`/`containment_achieved`/`workdir_exec` summary fields — they are read
33    /// off this report rather than passed in beside it, so a trace cannot claim one containment
34    /// class at the top level and another inside the report.
35    effective_grants: ScopeReport,
36    /// How much of each turn's driver request this trace keeps — see [`TraceCapture`]. Decides
37    /// whether an `inference` event carries content hashes at all, whether the bodies behind
38    /// them reach [`Self::blobs`], and whether `tool_call.output` is written.
39    capture: TraceCapture,
40    /// The content-addressed bodies this session stored, at `<workdir>/blobs/<sha256>`. Only
41    /// written to under [`TraceCapture::Content`]; the directory is created on the first blob.
42    blobs: BlobStore,
43    /// Where the effective system prompt came from: `"manifest"`, `"cli"` or `"none"`. Derived
44    /// once in [`TraceWriter::open`] from the resolved prompt and the override flag, then repeated
45    /// on every `session_start` the same way `model` is — the prompt cannot change between the
46    /// tasks of one session.
47    system_prompt_source: &'static str,
48    /// SHA-256 (lowercase hex) of the resolved system prompt, or `None` when no prompt is in
49    /// effect. Always written to `session_start` (as `null` when absent) so a trace records *that*
50    /// a prompt was in effect and which one, even when the text itself is withheld.
51    system_prompt_sha256: Option<String>,
52    /// Where the inference credential came from: `"config"`, `"environment"`, `"manifest"` or
53    /// `"none"`. Set by [`Self::set_credential_source`] before `session_start` is written.
54    credential_source: &'static str,
55    /// The session `mur run --resume` continued, verbatim as the operator's address resolved it.
56    /// `None` on every ordinary launch. Written to `session_start` on both, so its absence
57    /// identifies a trace from a runtime that predates the key.
58    resumed_from: Option<String>,
59    /// The launch-scoped context id: the `--context` value, or the id `--resume` resolved to.
60    /// `None` when each task of this launch mints its own.
61    context_id: Option<String>,
62    /// The session that spawned this one, and the delegation that created it, read out of the
63    /// handle the spawning capsule's launcher injected. Both `None` for a capsule nobody
64    /// delegated, and both omitted from `session_start` when absent rather than written as null.
65    spawned_by: Option<String>,
66    spawn_delegation_id: Option<String>,
67    session_start_time: Instant,
68    /// The session node of the event tree: the `event_id` `session_start` carries, and the
69    /// `parent_id` every launch-scoped event names. Minted in [`TraceWriter::open`] rather than
70    /// at write time so [`ResourceTraceAppender`], which is opened before the frame is written,
71    /// can be handed the same value.
72    session_event_id: String,
73    session_started: bool,
74    session_ended: bool,
75    /// The task node: the `event_id` of the active task's `task_start`. Set by
76    /// [`Self::write_task_start`], cleared by [`Self::write_task_end`], `None` between tasks.
77    task_event_id: Option<String>,
78    /// The turn node: the `event_id` of the active turn's own agent-loop `inference`. Every
79    /// event a turn produces hangs off it. Cleared at both task boundaries so a new task never
80    /// parents its first events to the previous task's last turn.
81    turn_event_id: Option<String>,
82    // Running totals — updated as events are written, used for fallback session_end on error exit
83    total_turns: u32,
84    total_input_tokens: u64,
85    total_output_tokens: u64,
86    total_tool_calls: u32,
87    total_shell_calls: u32,
88    // Per-task counters — reset on each write_task_start; consumed by write_task_end
89    task_turns: u32,
90    task_input_tokens: u64,
91    task_output_tokens: u64,
92    task_tool_calls: u32,
93    task_shell_calls: u32,
94    task_start_instant: Option<Instant>,
95    pub(crate) active_task_id: Option<String>,
96    /// The spend ceilings `session_start` records. Set by [`Self::set_spend_ceilings`].
97    max_session_tokens: Option<u64>,
98    machine_tokens_per_day: Option<u64>,
99}
100
101/// Provenance of an inference record that did **not** come from the agent
102/// loop's own turn — today, only a hook's `run-inference` call. `None` at a
103/// `write_inference`/`emit_inference` call site means "ordinary agent-loop
104/// turn", which serializes exactly as it did before this existed.
105#[derive(Debug, Clone)]
106pub(crate) struct InferenceOrigin {
107    /// `hook:<manifest name>` of the hook that made the call.
108    pub(crate) source: String,
109    /// Model string actually sent (the attempted model, on failure).
110    pub(crate) model: String,
111}
112
113/// `compaction_declined.reason` when the threshold was crossed but no bound hook returned
114/// `replace-context`.
115pub(crate) const COMPACTION_DECLINED_NO_HOOK_REPLACEMENT: &str = "no_hook_replacement";
116
117/// `compaction_declined.reason` when a hook's replacement was rejected because its tool calls
118/// and tool results did not pair up.
119pub(crate) const COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL: &str = "unresolved_tool_call";
120
121/// `context_seed.outcome` when the whole proposal fit the budget and was committed as-is.
122pub(crate) const SEED_OUTCOME_SEEDED: &str = "seeded";
123
124/// `context_seed.outcome` when the proposal was over budget and the oldest messages were
125/// dropped from its front until the rest fit.
126pub(crate) const SEED_OUTCOME_TRIMMED: &str = "trimmed";
127
128/// `context_seed.outcome` when the overflowing front was handed to the compaction hook and
129/// its summary became the seed's first message.
130pub(crate) const SEED_OUTCOME_COMPACTED: &str = "compacted";
131
132/// `context_seed.outcome` when nothing was committed. Always paired with a `reason`.
133pub(crate) const SEED_OUTCOME_REJECTED: &str = "rejected";
134
135/// `context_seed.reason` when one message alone is wider than the whole budget. Trimming
136/// cannot help — dropping everything else still leaves it over — so nothing is committed.
137pub(crate) const SEED_REJECTED_MESSAGE_OVER_BUDGET: &str = "message_over_budget";
138
139/// `context_seed.reason` when the proposal overflows the budget by more multiples of it than
140/// the runtime will summarize. A seed that far over is a broken hook, not a full memory.
141pub(crate) const SEED_REJECTED_OVERFLOW_OVER_LIMIT: &str = "overflow_over_limit";
142
143/// `context_seed.reason` when the capsule declares no `context.max_tokens`, so
144/// `context.seed_budget` is a fraction of nothing and there is no ceiling to enforce.
145pub(crate) const SEED_REJECTED_NO_BUDGET: &str = "no_budget";
146
147/// `context_seed.reason` when the session runs `inference.transport: process`, which owns its
148/// own context and has no host-side message list to seed.
149pub(crate) const SEED_REJECTED_UNSUPPORTED_TRANSPORT: &str = "unsupported_transport";
150
151/// `retention.store` for the session directories under a workdir.
152pub(crate) const RETENTION_STORE_SESSIONS: &str = "sessions";
153
154/// `retention.store` for the conversation records under `~/.murmur/conversations/`.
155pub(crate) const RETENTION_STORE_RECORDS: &str = "records";
156
157/// `retention.reason` when `trace.retain.max_sessions` put a session outside the newest N.
158pub(crate) const RETENTION_REASON_MAX_SESSIONS: &str = "max_sessions";
159
160/// `retention.reason` when `trace.retain.max_age` or `context.retain.max_age` put a session or a
161/// record outside the window.
162pub(crate) const RETENTION_REASON_MAX_AGE: &str = "max_age";
163
164/// `retention.reason` when `context.retain.max_messages` truncated the front of a record.
165pub(crate) const RETENTION_REASON_MAX_MESSAGES: &str = "max_messages";
166
167/// Mint one event identity: `evt_` + a UUID v7 in simple form, the same scheme `ses_`, `tsk_`,
168/// `ctx_`, `dep_` and `req_` use. Ids therefore sort by mint time and carry their own
169/// millisecond timestamp, so a trace can be ordered and time-bounded without reading any
170/// payload field.
171///
172/// Called once per line at the moment of the write. An id is never reused, never derived from
173/// the event's content, and never reconstructed from anything else in the file.
174fn new_event_id() -> String {
175    format!("evt_{}", uuid::Uuid::now_v7().simple())
176}
177
178// ── Event structs (Serialize → JSONL lines) ──────────────────────────────────
179//
180// Every struct opens with `event_type`, `event_id`, `parent_id`, `session_id` and `timestamp`,
181// in that order, so a human scanning the file reads a line's identity before its payload.
182// `parent_id` is the `event_id` of the event this one hangs off — see [`TraceWriter`]'s
183// `session_parent`/`task_parent`/`turn_parent` for which node each event names, and
184// [`new_event_id`] for the id format.
185//
186// The turn-level events (`inference`, `tool_call`, `skill_call`, `shell`, `compaction`,
187// `compaction_declined`) additionally carry `task_id`, taken from the writer's active task and
188// always written — `null` when no task is in scope. `task_start`/`task_end` carry their own and
189// are not duplicated.
190
191#[derive(Serialize)]
192struct SessionStartEvent {
193    event_type: &'static str,
194    event_id: String,
195    parent_id: Option<String>,
196    session_id: String,
197    timestamp: u64,
198    capsule_name: String,
199    capsule_version: String,
200    model: String,
201    max_turns: u32,
202    /// `inference.max_session_tokens` as this session enforces it. Always written, `null` when
203    /// no session ceiling applies.
204    max_session_tokens: Option<u64>,
205    /// `spend.machine_tokens_per_day` when this session keeps the machine ledger. Always written,
206    /// `null` when the machine ceiling does not cover this session.
207    machine_tokens_per_day: Option<u64>,
208    capabilities: Vec<String>,
209    tools_declared: Vec<String>,
210    /// Strongest containment class any source asked for. A *requirement*, not an observation.
211    containment_declared: ContainmentClass,
212    /// What this session actually ran under: the probed enforcement tier's ceiling, capped by
213    /// `workdir_exec` below. Reading these two together is how a session's trace shows whether the
214    /// capsule ran under the containment its operator asked for.
215    containment_achieved: ContainmentClass,
216    /// `capabilities.filesystem.workdir_exec`. Always written, including `false`, so its absence
217    /// identifies a trace from a runtime that predates the key rather than a capsule that declined
218    /// it.
219    ///
220    /// `true` is the reason a trace can show `containment_achieved: advisory` on a host whose
221    /// `session_start` would otherwise say `scoped` — and it is the record that this session's
222    /// `capabilities.shell.allow` was advisory too, since anything in the workdir could run.
223    workdir_exec: bool,
224    /// Where this host's permission to create an unprivileged user namespace came from, as
225    /// `UsernsGrant::wire_name` — `null` only off Linux, where AppArmor does not exist.
226    ///
227    /// Mirrored to the top level from `effective_grants` for the same reason `workdir_exec` is:
228    /// an auditor should not have to descend into a nested object to answer it. Recorded because
229    /// `containment_achieved: sealed` reached through the shipped AppArmor profile and the same
230    /// class reached on a host whose unprivileged-userns hardening is switched off for every
231    /// binary are two very different records, and this key is the only thing separating them.
232    ///
233    /// Always written, on the same terms as `workdir_exec`: its absence identifies a trace from a
234    /// runtime that predates the key.
235    userns_grant: Option<crate::sealed::UsernsGrant>,
236    /// The complete grant set this session ran under — every destination, binary, path and
237    /// environment variable the policy actually opened, plus the probed enforcement tier — in the
238    /// exact shape `mur run --explain-scope --json` prints for the same policy on the same host.
239    ///
240    /// `capabilities` above stays what it always was: a list of category *names*. It answers "did
241    /// this session have network access?" but not "to where?", which is the question an auditor
242    /// reading a finished trace actually has. This field answers it without re-parsing the
243    /// manifest, which by then may have changed or moved.
244    effective_grants: ScopeReport,
245    /// Where the system prompt this session ran with came from: `"manifest"` when the manifest's
246    /// own `inference.system_prompt`/`system_prompt_file`/`system_prompt_artifact` supplied it,
247    /// `"cli"` when `mur run --system-prompt` overrode it (including when the override was empty
248    /// and therefore cleared it), `"none"` when no prompt was in effect at all.
249    ///
250    /// Always written, on the same terms as `workdir_exec`: its absence identifies a trace from a
251    /// runtime that predates the key, not a session that had no prompt.
252    system_prompt_source: &'static str,
253    /// SHA-256 (lowercase hex) of the resolved prompt — the text as `resolve_system_prompt`
254    /// returned it, before the `[Capsule]` identity block is prepended — or `null` when no prompt
255    /// was in effect. Always written, so two sessions can be compared for prompt equality without
256    /// either trace having to carry the prompt itself.
257    ///
258    /// Deliberately not the same value as `inference.system_sha`, which covers the augmented
259    /// prompt that actually went on the wire: this one names the prompt the operator wrote.
260    /// Under `trace.capture: content` those resolved bytes are also the blob this hash names.
261    system_prompt_sha256: Option<String>,
262    /// Where the inference credential this session attaches came from: `"config"`,
263    /// `"environment"`, `"manifest"` or `"none"`. Never the value.
264    credential_source: &'static str,
265    /// The session `mur run --resume` continued, or `null` on an ordinary launch. Together with
266    /// `context_id` below it is what makes a resumed conversation followable back through the
267    /// sessions that built it.
268    ///
269    /// Always written, on the same terms as `workdir_exec`: its absence identifies a trace from a
270    /// runtime that predates the key.
271    resumed_from: Option<String>,
272    /// The context id every task of this launch runs under — the `mur run --context` value, or
273    /// the id `--resume` resolved to — and `null` when each task mints its own. `task_start`
274    /// carries the id a task actually ran under either way.
275    ///
276    /// Always written, on the same terms as `resumed_from`.
277    context_id: Option<String>,
278    /// The `ses_` id of the session that spawned this one, for a capsule launched by another
279    /// capsule's `delegate-task`. Omitted from the record entirely for every other launch: the
280    /// field is absent, not null, on the same terms as `task_start.delegation_id`.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    spawned_by: Option<String>,
283    /// The `dlg_` id of the delegation that created this session, character-identical to the id
284    /// in the spawning session's own `delegation_start`. Present exactly when `spawned_by` is.
285    #[serde(skip_serializing_if = "Option::is_none")]
286    delegation_id: Option<String>,
287}
288
289#[derive(Serialize)]
290struct InferenceEvent {
291    event_type: &'static str,
292    event_id: String,
293    parent_id: Option<String>,
294    session_id: String,
295    timestamp: u64,
296    turn: u32,
297    task_id: Option<String>,
298    /// The runtime's own tiktoken estimate of the request, counted before the request was
299    /// sent — not the provider's count, which arrives afterwards as `input_tokens_actual`.
300    input_tokens: u64,
301    /// The runtime's own tiktoken estimate of the raw driver response.
302    output_tokens: u64,
303    decision: String,
304    /// The provider's own stop reason for this turn, verbatim as the agent loop dispatched on
305    /// it. Present on every agent-loop turn, including one whose driver reported nothing — an
306    /// empty string then, because that is what the loop dispatched on. Absent means no driver
307    /// response was parsed at all: a hook's `run-inference` and the `process` transport both
308    /// name their `decision` locally and never saw a provider stop reason.
309    #[serde(skip_serializing_if = "Option::is_none")]
310    stop_reason: Option<String>,
311    tool_name: Option<String>,
312    /// The provider's own counts for this call, each written only when the driver reported
313    /// it. Absent means the driver reported nothing, never zero.
314    #[serde(skip_serializing_if = "Option::is_none")]
315    input_tokens_actual: Option<u64>,
316    #[serde(skip_serializing_if = "Option::is_none")]
317    output_tokens_actual: Option<u64>,
318    #[serde(skip_serializing_if = "Option::is_none")]
319    cached_tokens: Option<u64>,
320    #[serde(skip_serializing_if = "Option::is_none")]
321    cache_write_tokens: Option<u64>,
322    /// Where this inference came from. Absent for an ordinary agent-loop turn,
323    /// so every pre-existing consumer sees a byte-identical record; `"hook:<name>"`
324    /// for a completion that hook ran through `murmur:runtime/inference`'s
325    /// `run-inference`.
326    #[serde(skip_serializing_if = "Option::is_none")]
327    origin: Option<String>,
328    /// Model string actually sent for this call (the attempted model, on
329    /// failure). Only written alongside `origin`: an agent-loop turn's model is
330    /// already on the session-start record and is not repeated per turn.
331    #[serde(skip_serializing_if = "Option::is_none")]
332    model: Option<String>,
333    /// Identities of the messages this request embedded, in the order they sat in it. Under an
334    /// active driver continuation only the tail past the acked length is sent, and this names
335    /// exactly that tail. Empty — and therefore absent — for a hook's own `run-inference` and for
336    /// the `process` transport, neither of which sends a message list the runtime minted.
337    #[serde(skip_serializing_if = "Vec::is_empty")]
338    message_ids: Vec<String>,
339    /// SHA-256 (lowercase hex) of the UTF-8 bytes of this request's `system` string — the
340    /// augmented prompt with the `[Capsule]` block already in it.
341    ///
342    /// These are the bytes Murmur **sent**, not what the model **saw**: provider-side injection,
343    /// tokenizer differences and safety layers all happen past the wire and are invisible to the
344    /// runtime. Absent under `trace.capture: none`, and for a record the runtime did not build
345    /// the payload for — a hook's `run-inference`, and the `process` transport.
346    #[serde(skip_serializing_if = "Option::is_none")]
347    system_sha: Option<String>,
348    /// SHA-256 (lowercase hex) of this request's serialized `tools` array. The bytes Murmur
349    /// sent, on the same terms as [`Self::system_sha`].
350    #[serde(skip_serializing_if = "Option::is_none")]
351    tools_sha: Option<String>,
352    /// SHA-256 (lowercase hex) of the raw driver response body, as the loop read it before
353    /// parsing. What the driver returned to Murmur, byte for byte.
354    #[serde(skip_serializing_if = "Option::is_none")]
355    response_sha: Option<String>,
356    /// SHA-256 (lowercase hex) of each message this request embedded, in send order — one entry
357    /// per `message_ids` entry, over the same messages after the runtime's identity keys are
358    /// stripped. The bytes Murmur sent, on the same terms as [`Self::system_sha`].
359    ///
360    /// Not redundant with `message_ids`: an id names an entity and is freshly minted every run,
361    /// so comparing two runs' id arrays only says every id differs. Comparing these says where
362    /// the two prompts stopped agreeing — the divergence index is the first unequal position.
363    #[serde(skip_serializing_if = "Vec::is_empty")]
364    message_shas: Vec<String>,
365}
366
367/// The bodies one driver request put on the wire, split into the pieces an `inference` event
368/// hashes.
369///
370/// Built from the payload [`crate::agent::build_driver_payload`] produced and the raw response
371/// string the driver returned, so what the trace names and what the driver received cannot
372/// drift: there is no second serialization for them to drift apart in.
373///
374/// Every piece is the bytes Murmur **sent** (or, for the response, was handed back), never what
375/// the model saw.
376pub(crate) struct WireCapture {
377    /// The payload's `system` string value as UTF-8 text — the augmented prompt, `[Capsule]`
378    /// block included. Stored as text so its blob `cat`s as a readable prompt.
379    system: Vec<u8>,
380    /// The payload's `tools` array, serialized.
381    tools: Vec<u8>,
382    /// Each element of the payload's `messages` array, serialized, in send order. That array is
383    /// the `wire_messages` slice after `strip_message_identity`, so no message identity key can
384    /// reach a hash or a blob.
385    messages: Vec<Vec<u8>>,
386    /// The raw driver response body, exactly as the loop read it.
387    response: Vec<u8>,
388}
389
390impl WireCapture {
391    /// Split a built driver payload and its raw response into the four bodies the trace hashes.
392    ///
393    /// `payload` must be the value that was serialized and handed to the driver; taking the
394    /// pieces out of it, rather than rebuilding them from the loop's own state, is what makes a
395    /// blob byte-identical to the substring of the request it came from.
396    pub(crate) fn from_driver_payload(payload: &Value, response: &str) -> Self {
397        Self {
398            system: payload
399                .get("system")
400                .and_then(Value::as_str)
401                .unwrap_or_default()
402                .as_bytes()
403                .to_vec(),
404            tools: payload
405                .get("tools")
406                .map(|tools| serde_json::to_vec(tools).unwrap_or_default())
407                .unwrap_or_default(),
408            messages: payload
409                .get("messages")
410                .and_then(Value::as_array)
411                .map(|messages| {
412                    messages
413                        .iter()
414                        .map(|message| serde_json::to_vec(message).unwrap_or_default())
415                        .collect()
416                })
417                .unwrap_or_default(),
418            response: response.as_bytes().to_vec(),
419        }
420    }
421}
422
423#[derive(Serialize)]
424struct ToolCallEvent {
425    event_type: &'static str,
426    event_id: String,
427    parent_id: Option<String>,
428    session_id: String,
429    timestamp: u64,
430    turn: u32,
431    task_id: Option<String>,
432    tool_name: String,
433    /// The provider's own id for this call, as it appeared on the driver response block that
434    /// asked for it. Recorded verbatim and never parsed — the same contract `resource_id` has.
435    /// `null` when the provider named none, which is how the codex dialect's inline tool items
436    /// and every host-synthesized call read.
437    tool_call_id: Option<String>,
438    input: Value,
439    input_bytes: u64,
440    #[serde(skip_serializing_if = "Option::is_none")]
441    output: Option<String>,
442    output_bytes: u64,
443    duration_ms: u64,
444    status: String,
445    /// The tool's self-declared `state_effect` for this call (`read`/`mutate`),
446    /// lifted verbatim from `tool-result.metadata`. Absent when the tool declared
447    /// nothing; consumers treat absence conservatively. See `wit/tool.wit`.
448    #[serde(skip_serializing_if = "Option::is_none")]
449    state_effect: Option<String>,
450    /// The resource this call addressed, as declared by the tool and lifted verbatim
451    /// from `tool-result.metadata`. An opaque, tool-defined string — never parsed here.
452    /// Absent when the tool declared nothing, in which case consumers fall back to
453    /// guessing the resource from the call's input. See `wit/tool.wit`.
454    #[serde(skip_serializing_if = "Option::is_none")]
455    resource_id: Option<String>,
456}
457
458#[derive(Serialize)]
459struct SkillCallEvent {
460    event_type: &'static str,
461    event_id: String,
462    parent_id: Option<String>,
463    session_id: String,
464    timestamp: u64,
465    turn: u32,
466    task_id: Option<String>,
467    skill_name: String,
468    output_bytes: u64,
469    duration_ms: u64,
470    status: String,
471}
472
473#[derive(Serialize)]
474struct ShellEvent {
475    event_type: &'static str,
476    event_id: String,
477    parent_id: Option<String>,
478    session_id: String,
479    timestamp: u64,
480    turn: u32,
481    task_id: Option<String>,
482    /// The program that ran — a canonical absolute path when the host `PATH` resolved
483    /// the invoked name, else the bare name. `command` carries the argument list alone,
484    /// so this is the only field that says *what* ran.
485    binary: String,
486    command: String,
487    exit_code: i32,
488    stdout_bytes: u64,
489    stderr_bytes: u64,
490    duration_ms: u64,
491    /// The `capabilities.resources` field this subprocess was killed for exceeding, when the
492    /// kernel's own evidence names exactly one (`SIGXCPU`/`SIGXFSZ`, or a cgroup
493    /// `memory.events`/`pids.events` counter that moved). Omitted from the JSONL entirely
494    /// otherwise — including for a subprocess that died for a reason no single limit can be
495    /// pinned to, which must not read as "no limit was involved".
496    #[serde(skip_serializing_if = "Option::is_none")]
497    resource_limit: Option<String>,
498}
499
500/// A shell command that outran its grace period and moved to the background. Hangs off the turn
501/// that started it, so `mur trace steps` shows it where the model saw it.
502#[derive(Serialize)]
503struct ShellDetachedEvent {
504    event_type: &'static str,
505    event_id: String,
506    parent_id: Option<String>,
507    session_id: String,
508    timestamp: u64,
509    turn: u32,
510    task_id: Option<String>,
511    work_id: String,
512    binary: String,
513    command: String,
514    /// The `lifecycle.shell_grace_secs` in force, in milliseconds — what this command outran.
515    grace_ms: u64,
516}
517
518/// A detached command finishing, written where the task loop drains it. Hangs off the session
519/// rather than a turn: by the time it lands, the turn that started the command is over.
520#[derive(Serialize)]
521struct ShellCompletedEvent {
522    event_type: &'static str,
523    event_id: String,
524    parent_id: Option<String>,
525    session_id: String,
526    timestamp: u64,
527    work_id: String,
528    binary: String,
529    command: String,
530    exit_code: i32,
531    duration_ms: u64,
532    /// Workdir-relative path of the file holding the command's full stdout and stderr.
533    output_path: String,
534    output_bytes: u64,
535    /// Attributed the same way [`ShellEvent::resource_limit`] is, and omitted on the same terms.
536    #[serde(skip_serializing_if = "Option::is_none")]
537    resource_limit: Option<String>,
538    /// `"ok"` or `"error"` — the field that tells a failed background command from a successful
539    /// one without reading its prose.
540    status: String,
541    /// The `completion`-origin task this completion was enqueued as, which is what makes "which
542    /// command produced this task" answerable from `trace.jsonl` alone.
543    completion_task_id: String,
544}
545
546/// A detached command the session ended without carrying its result back. No task will ever report
547/// it; this record and the operator report beside it are what keep the loss visible.
548///
549/// The command was either still running at the sweep or finished during teardown, and the second
550/// case knows more: [`Self::exit_code`], [`Self::output_path`] and [`Self::output_bytes`] are
551/// written together for it and omitted together for the first. A command still running therefore
552/// produces a line byte-identical in shape to one written before those fields existed — omitted,
553/// never `null`, because `null` would read as a known-absent exit code rather than an unknown one.
554#[derive(Serialize)]
555struct ShellAbandonedEvent {
556    event_type: &'static str,
557    event_id: String,
558    parent_id: Option<String>,
559    session_id: String,
560    timestamp: u64,
561    work_id: String,
562    binary: String,
563    command: String,
564    /// How long the command had been running when the session gave up on it. For a command that
565    /// finished during teardown, its full duration from spawn to exit.
566    running_ms: u64,
567    /// `128 + signal` for a signal kill, as on `shell_completed`. Absent for a command still
568    /// running.
569    #[serde(skip_serializing_if = "Option::is_none")]
570    exit_code: Option<i32>,
571    /// Workdir-relative path of the file holding the command's full stdout and stderr, always
572    /// `logs/<work_id>.log`. Absent for a command still running, for which that file was never
573    /// written and never will be.
574    #[serde(skip_serializing_if = "Option::is_none")]
575    output_path: Option<String>,
576    /// Size of that file. Present exactly when [`Self::output_path`] is.
577    #[serde(skip_serializing_if = "Option::is_none")]
578    output_bytes: Option<u64>,
579}
580
581/// A demoted command a later launch found unaccounted for, appended to the `trace.jsonl` of the
582/// session that started it.
583///
584/// Carries no `exit_code`, no `status`, no `duration_ms`, no `output_path` and no `output_bytes`:
585/// none of them exists for a command whose runtime died, and a record that named any of them
586/// would be readable as a result. The event type is the whole discriminator against
587/// [`ShellCompletedEvent`].
588///
589/// Its presence is also what stops the same work id being reported by a second resume, so this
590/// line and the `shell_detached` it answers live in the same file and are pruned together by
591/// [`crate::retention::prune_sessions`].
592#[derive(Serialize)]
593struct ShellLostEvent {
594    event_type: &'static str,
595    event_id: String,
596    /// The prior session's own `session_start` node. Omitted from the line when that record could
597    /// not be read back — a line must never name a parent the file does not hold.
598    #[serde(skip_serializing_if = "Option::is_none")]
599    parent_id: Option<String>,
600    /// The prior session's id, not the resuming one's, so the file stays internally consistent.
601    session_id: String,
602    timestamp: u64,
603    work_id: String,
604    binary: String,
605    command: String,
606    /// The `shell_detached` line's own timestamp.
607    detached_at_ms: u64,
608    /// The session that found the work unaccounted for and reported it.
609    reconciled_by_session: String,
610    /// The `completion`-origin task that session enqueued to report it, which is what makes "which
611    /// task reported this loss" answerable from the two files alone.
612    reconciled_task_id: String,
613}
614
615/// A demotion whose own `shell_detached` line could not be written.
616///
617/// Best-effort and usually absent: it goes to the file that just failed. When it does land, it is
618/// the record that the work id below has no `shell_detached` line and so will never be reported
619/// as lost.
620#[derive(Serialize)]
621struct ShellDetachUnrecordedEvent {
622    event_type: &'static str,
623    event_id: String,
624    parent_id: Option<String>,
625    session_id: String,
626    timestamp: u64,
627    turn: u32,
628    task_id: Option<String>,
629    work_id: String,
630    binary: String,
631    /// The write error, as the operating system reported it.
632    reason: String,
633}
634
635#[derive(Serialize)]
636struct CompactionEvent {
637    event_type: &'static str,
638    event_id: String,
639    parent_id: Option<String>,
640    session_id: String,
641    timestamp: u64,
642    turn: u32,
643    task_id: Option<String>,
644    tokens_before: u64,
645    tokens_after: u64,
646}
647
648/// The compaction threshold was crossed and the context was left as it was. The session
649/// continues over budget, so the record of *why* is the only thing separating this from a
650/// session that never needed compacting at all.
651#[derive(Serialize)]
652struct CompactionDeclinedEvent {
653    event_type: &'static str,
654    event_id: String,
655    parent_id: Option<String>,
656    session_id: String,
657    timestamp: u64,
658    turn: u32,
659    task_id: Option<String>,
660    /// Context occupancy at the moment of the decline — the same measurement `compaction`
661    /// records as `tokens_before`, and the budget the session went on running over.
662    tokens: u64,
663    /// `"no_hook_replacement"` when no bound hook returned `replace-context`;
664    /// `"unresolved_tool_call"` when the replacement's tool calls and tool results did not pair.
665    reason: String,
666}
667
668/// An `on-task-start` hook proposed context and the runtime decided what to do with it.
669///
670/// Written once per task that had a seeding hook return something, whatever the decision —
671/// including a rejection, where `tokens` is `0`. A seed that was silently discarded is
672/// indistinguishable from a capsule with no memory hook at all, which is the failure this
673/// record exists to make impossible.
674#[derive(Serialize)]
675struct ContextSeedEvent {
676    event_type: &'static str,
677    event_id: String,
678    parent_id: Option<String>,
679    session_id: String,
680    timestamp: u64,
681    task_id: Option<String>,
682    /// Manifest name of the hook that returned the `seed-context`.
683    hook_name: String,
684    /// Tokens actually committed to the head of the context: `0` for a rejection, the
685    /// proposal's own count for `seeded`, and the surviving count for `trimmed`/`compacted`.
686    tokens: u64,
687    /// Tokens the hook proposed, before any trim or summarization. Together with
688    /// `budget_tokens` this is what makes a rejection readable without the hook's own log.
689    proposed_tokens: u64,
690    /// The ceiling in force: `floor(context.max_tokens * context.seed_budget)`, or `0` when
691    /// the capsule declares no `context.max_tokens`.
692    budget_tokens: u64,
693    /// `"seeded"`, `"trimmed"`, `"compacted"` or `"rejected"` — see the `SEED_OUTCOME_*`
694    /// constants.
695    outcome: String,
696    /// Why nothing was committed, on `"rejected"` only — see the `SEED_REJECTED_*`
697    /// constants. Absent from the JSONL for every other outcome.
698    #[serde(skip_serializing_if = "Option::is_none")]
699    reason: Option<String>,
700    /// The `msg_`-prefixed id of every message committed, in the order they were placed.
701    /// Empty on a rejection. These are the ids the runtime minted; they never reach the
702    /// driver payload, so this record is the only place they are visible.
703    message_ids: Vec<String>,
704}
705
706/// Retention deleted something. One line per (store, reason) pair that removed anything, in the
707/// trace of the session that performed the deletion — the only place the pruning of session N's
708/// predecessors can go.
709///
710/// Session-scoped, and written immediately after `session_start`: a session directory that
711/// vanishes with no explanation makes "where did my trace go" unanswerable, and the answer has to
712/// be in the trace of the run that did it.
713#[derive(Serialize)]
714struct RetentionEvent {
715    event_type: &'static str,
716    event_id: String,
717    parent_id: Option<String>,
718    session_id: String,
719    timestamp: u64,
720    /// [`RETENTION_STORE_SESSIONS`] or [`RETENTION_STORE_RECORDS`].
721    store: String,
722    /// [`RETENTION_REASON_MAX_SESSIONS`], [`RETENTION_REASON_MAX_AGE`] or
723    /// [`RETENTION_REASON_MAX_MESSAGES`].
724    reason: String,
725    /// How many units this reason removed from this store: session directories, context
726    /// directories, or — for a truncation — the one record that was rewritten. Never zero;
727    /// nothing removed means no event.
728    removed: u32,
729    /// What went: `ses_` directory names, or context ids.
730    targets: Vec<String>,
731    /// Messages dropped from the front of the record. Written for
732    /// [`RETENTION_REASON_MAX_MESSAGES`] only, and absent from the JSONL otherwise.
733    #[serde(skip_serializing_if = "Option::is_none")]
734    messages_dropped: Option<u64>,
735}
736
737#[derive(Serialize)]
738struct A2aTaskReceivedEvent {
739    event_type: &'static str,
740    event_id: String,
741    parent_id: Option<String>,
742    session_id: String,
743    timestamp: u64,
744    task_id: String,
745    context_id: String,
746    message_id: String,
747    traceparent_from_caller: Option<String>,
748}
749
750#[derive(Serialize)]
751struct A2aSendEvent {
752    event_type: &'static str,
753    event_id: String,
754    parent_id: Option<String>,
755    session_id: String,
756    timestamp: u64,
757    peer_url: String,
758    message_id: String,
759    task_id: String,
760    context_id: String,
761    traceparent: Option<String>,
762    /// The class this runtime stamped on the outgoing request, so a chain of capsules reads the
763    /// same way from the sending end as the receiving end's `task_start` reads it.
764    trust: String,
765}
766
767#[derive(Serialize)]
768struct SessionEndEvent {
769    event_type: &'static str,
770    event_id: String,
771    parent_id: Option<String>,
772    session_id: String,
773    timestamp: u64,
774    total_turns: u32,
775    total_input_tokens: u64,
776    total_output_tokens: u64,
777    total_tool_calls: u32,
778    total_shell_calls: u32,
779    duration_ms: u64,
780    exit_status: String,
781}
782
783#[derive(Serialize)]
784struct TaskStartEvent {
785    event_type: &'static str,
786    event_id: String,
787    parent_id: Option<String>,
788    session_id: String,
789    timestamp: u64,
790    task_id: String,
791    context_id: String,
792    /// Which door the task came through. `source` and `origin` answer different questions —
793    /// `"a2a"` covers both a trusted peer and an untyped webhook.
794    source: String,
795    origin: String,
796    trust: String,
797    /// The queue lane this task waited in, derived from `origin`. Recorded so the order two
798    /// tasks ran in is answerable from the trace alone.
799    lane: String,
800    /// The delegation whose completion this task is, for a `completion`-origin task that arrived
801    /// from a child this capsule launched. Omitted from the record entirely for every other task:
802    /// the field is absent, not null.
803    #[serde(skip_serializing_if = "Option::is_none")]
804    delegation_id: Option<String>,
805    message_parts_bytes: u64,
806}
807
808#[derive(Serialize)]
809struct TaskEndEvent {
810    event_type: &'static str,
811    event_id: String,
812    parent_id: Option<String>,
813    session_id: String,
814    timestamp: u64,
815    task_id: String,
816    exit_status: String,
817    duration_ms: u64,
818    turns: u32,
819    input_tokens: u64,
820    output_tokens: u64,
821    tool_calls: u32,
822    shell_calls: u32,
823    /// How many times this task's agent loop was reopened by an `on-task-end` hook
824    /// before the terminal outcome. `0` for a task that ran once (the common case).
825    reopen_count: u32,
826}
827
828/// A person stopped this task. Written where the loop actually stopped, so the record names the
829/// wait that was interrupted and everything that was still running at that moment.
830///
831/// Distinct from the `task_end` that follows it: `task_end` says the task is over and with which
832/// `exit_status`, this says a person made that happen and what it left behind. A task cancelled
833/// while still queued has no `task_start` and gets no `task_end`, so this is its only record.
834#[derive(Serialize)]
835struct TaskCanceledEvent {
836    event_type: &'static str,
837    event_id: String,
838    parent_id: Option<String>,
839    session_id: String,
840    timestamp: u64,
841    task_id: String,
842    /// The turn that was in flight, 0-based. Absent for a task cancelled before it ever ran.
843    #[serde(skip_serializing_if = "Option::is_none")]
844    turn: Option<u32>,
845    /// Where cancellation landed: `queued`, `turn`, `inference`, `input` or `delegation`.
846    phase: String,
847    /// Demoted shell commands still running when the loop stopped. They keep their own
848    /// lifecycle — nothing here was killed.
849    detached_work_ids: Vec<String>,
850    /// Delegations still in flight when the loop stopped, left running exactly as a delegation
851    /// deadline leaves them.
852    delegation_ids: Vec<String>,
853}
854
855/// One `on-task-end` hook reopened the task: its agent loop is about to re-run with
856/// the hook's feedback injected. Written between two agent-loop attempts for the same
857/// task, so `mur trace show` can show which hook drove each reopen and why.
858#[derive(Serialize)]
859struct TaskReopenedEvent {
860    event_type: &'static str,
861    event_id: String,
862    parent_id: Option<String>,
863    session_id: String,
864    timestamp: u64,
865    task_id: String,
866    /// Manifest name of the `on-task-end` hook that returned `reopen-task`.
867    hook_name: String,
868    /// Feedback text the hook asked to inject into the reopened task content.
869    reason: String,
870    /// 1-based ordinal of this reopen within the task (first reopen = 1).
871    reopen_number: u32,
872}
873
874/// A spend ceiling refused a driver call before it was sent. No `inference` line accompanies it,
875/// because no call was made.
876#[derive(Serialize)]
877struct SpendCeilingReachedEvent<'a> {
878    event_type: &'static str,
879    event_id: String,
880    parent_id: Option<String>,
881    session_id: String,
882    timestamp: u64,
883    turn: u32,
884    task_id: Option<String>,
885    #[serde(flatten)]
886    refusal: &'a crate::spend::SpendRefusal,
887    /// `hook:<name>` for a hook's `run-inference` call; absent for an agent-loop turn.
888    #[serde(skip_serializing_if = "Option::is_none")]
889    origin: Option<&'a str>,
890}
891
892/// A policy hook refused a call before it ran. The one record that says a call the model asked
893/// for never happened — there is no `tool_call` or `shell` line for a denied call, because
894/// nothing ran.
895#[derive(Serialize)]
896struct CallDeniedEvent {
897    event_type: &'static str,
898    event_id: String,
899    parent_id: Option<String>,
900    session_id: String,
901    timestamp: u64,
902    turn: u32,
903    /// The gated lifecycle function whose decision point refused: `"on-shell"` or
904    /// `"on-tool-call"`.
905    event: String,
906    /// Manifest name of the policy hook that refused.
907    hook_name: String,
908    /// What was refused: the resolved executable path for a shell call, the tool name
909    /// otherwise.
910    target: String,
911    /// The hook's own reason, or the runtime's when the hook supplied none it could use.
912    reason: String,
913}
914
915/// The manifest's own read-only rule refused a call before it ran. Distinct from
916/// [`CallDeniedEvent`] because it names a different authority and a different subject: no hook was
917/// asked, and what it carries is the path, the declared rule and the evidence that identified the
918/// call as a write.
919#[derive(Serialize)]
920struct ProtectedPathDeniedEvent {
921    event_type: &'static str,
922    event_id: String,
923    parent_id: Option<String>,
924    session_id: String,
925    timestamp: u64,
926    turn: u32,
927    /// Which dispatch path was refused: `"shell"` or `"tool"`.
928    call: String,
929    /// What was refused: the resolved executable path for a shell call, the tool name otherwise.
930    target: String,
931    /// The resolved workdir-relative path, never the string the model typed, so two spellings of
932    /// one file produce one comparable record.
933    path: String,
934    /// The `capabilities.filesystem.read_only` entry that covers `path`, as declared.
935    rule: String,
936    /// What identified the call as a write — a redirection operator, a write-verb argument
937    /// position, or the tool-input key pairing.
938    signal: String,
939    /// The same text the model was given, so the trace and the agent agree on why.
940    reason: String,
941}
942
943#[derive(Serialize)]
944struct HookDispatchErrorEvent {
945    event_type: &'static str,
946    event_id: String,
947    parent_id: Option<String>,
948    session_id: String,
949    timestamp: u64,
950    hook_name: String,
951    event: String,
952    arm: String,
953}
954
955#[derive(Serialize)]
956struct ResourceListEvent {
957    event_type: &'static str,
958    event_id: String,
959    parent_id: Option<String>,
960    session_id: String,
961    timestamp: u64,
962    /// `exports.files.root` verbatim, or `""` when the capsule declares no export — a refusal on
963    /// an undeclared plane is still a complete record of what was asked for.
964    root: String,
965    entry_count: usize,
966    total_bytes: u64,
967    /// Which completed turn the listing is as of.
968    generation: u64,
969    containment_achieved: ContainmentClass,
970    /// `"ok"`, or the wire error code the request was refused with.
971    outcome: String,
972    /// `null` on `ok`, one short sentence otherwise.
973    reason: Option<String>,
974}
975
976#[derive(Serialize)]
977struct ResourceReadEvent {
978    event_type: &'static str,
979    event_id: String,
980    parent_id: Option<String>,
981    session_id: String,
982    timestamp: u64,
983    /// The requested path *after* percent-decoding, so `%2e%2e%2f` and `../` read as the same
984    /// attempt rather than as two unrelated findings.
985    path: String,
986    outcome: String,
987    /// `null` on every non-`ok` outcome.
988    bytes: Option<u64>,
989    /// `null` on every non-`ok` outcome.
990    sha256: Option<String>,
991    generation: u64,
992    containment_achieved: ContainmentClass,
993    reason: Option<String>,
994}
995
996/// One `share-file` call, served or refused, written at the moment of the mint.
997#[derive(Serialize)]
998struct PeerHandleMintEvent {
999    event_type: &'static str,
1000    event_id: String,
1001    parent_id: Option<String>,
1002    session_id: String,
1003    timestamp: u64,
1004    /// `null` on every non-`ok` outcome: a mint that was refused produced no token, so there is
1005    /// no handle to identify.
1006    handle_id: Option<String>,
1007    /// The path relative to `exports.peer_files.root`, canonicalised, or the path the agent asked
1008    /// for when the mint was refused. Never a host path and never the workdir-relative form.
1009    path: String,
1010    audience: String,
1011    /// `null` on every non-`ok` outcome.
1012    expires_at_ms: Option<u64>,
1013    outcome: String,
1014    reason: Option<String>,
1015}
1016
1017/// One redeem against `/resources/peer/<handle>`, written by the listener concurrently with a
1018/// possibly-running task.
1019#[derive(Serialize)]
1020struct PeerHandleRedeemEvent {
1021    event_type: &'static str,
1022    event_id: String,
1023    parent_id: Option<String>,
1024    session_id: String,
1025    timestamp: u64,
1026    handle_id: String,
1027    /// `null` until the MAC has verified. A payload that failed the MAC is caller-controlled and
1028    /// must not be copied into this capsule's own audit record as if it were fact.
1029    path: Option<String>,
1030    /// The runtime's own current counter, never a value taken from the token — so it is always
1031    /// present and always true.
1032    generation: u64,
1033    /// The `x-murmur-audience` header exactly as asserted, or `null` when none was sent.
1034    audience_asserted: Option<String>,
1035    /// `null` on every non-`ok` outcome.
1036    bytes: Option<u64>,
1037    /// `null` on every non-`ok` outcome.
1038    sha256: Option<String>,
1039    outcome: String,
1040    reason: Option<String>,
1041}
1042
1043/// One `fetch-peer-file` call on the ingesting side, served or refused.
1044#[derive(Serialize)]
1045struct PeerFileFetchEvent {
1046    event_type: &'static str,
1047    event_id: String,
1048    parent_id: Option<String>,
1049    session_id: String,
1050    timestamp: u64,
1051    peer: String,
1052    handle_id: String,
1053    /// Where the bytes landed, relative to the accessible workdir. `null` on every non-`ok`
1054    /// outcome.
1055    stored_path: Option<String>,
1056    bytes: Option<u64>,
1057    sha256: Option<String>,
1058    outcome: String,
1059    reason: Option<String>,
1060}
1061
1062/// One launched child, written the moment it is up.
1063///
1064/// Two surfaces launch children — the agent's `delegate-task` tool and a plan's `capsule` step —
1065/// and both write this line, off the same struct and off the same session node. Which one made a
1066/// given delegation is not recorded, because it is not a fact about the delegation.
1067///
1068/// Separate from the `delegation` line below because the two answer different questions and land
1069/// at different times: this one reaches disk while the delegation is still in flight, so a child
1070/// that hangs, crashes or is ended at its deadline is attributable from the parent's side whatever
1071/// happens next. A delegation the daemon refused writes none of these — nothing was launched.
1072#[derive(Serialize)]
1073struct DelegationStartEvent {
1074    event_type: &'static str,
1075    event_id: String,
1076    parent_id: Option<String>,
1077    session_id: String,
1078    timestamp: u64,
1079    /// The `dlg_` id the launcher minted, character-identical to the one on this delegation's
1080    /// terminal `delegation` line and in the child's own `session_start`.
1081    delegation_id: String,
1082    capsule: String,
1083    version: String,
1084    /// The session id the child's runtime minted for itself.
1085    child_session_id: String,
1086    /// The child's directory, relative to this capsule's accessible workdir — join the two to
1087    /// reach the child's own `trace.jsonl`.
1088    child_workdir: String,
1089}
1090
1091impl DelegationStartEvent {
1092    /// The one place this record is built, so the two appenders that write it cannot drift into
1093    /// two shapes. Which surface launched the child is not an argument, because it is not a field.
1094    fn new(
1095        session_id: &str,
1096        session_event_id: &str,
1097        delegation_id: &str,
1098        capsule: &str,
1099        version: &str,
1100        child_session_id: &str,
1101        child_workdir: &str,
1102    ) -> Self {
1103        Self {
1104            event_type: "delegation_start",
1105            event_id: new_event_id(),
1106            parent_id: Some(session_event_id.to_string()),
1107            session_id: session_id.to_string(),
1108            timestamp: timestamp_ms(),
1109            delegation_id: delegation_id.to_string(),
1110            capsule: capsule.to_string(),
1111            version: version.to_string(),
1112            child_session_id: child_session_id.to_string(),
1113            child_workdir: child_workdir.to_string(),
1114        }
1115    }
1116}
1117
1118/// One delegation, written when it ends.
1119///
1120/// One line per call whatever happened, including a call the daemon refused: a delegation that was
1121/// asked for and not made is as much a fact of the run as one that produced an answer. When it is
1122/// written depends on how far the delegation got — a `delegate-task` call that started a child
1123/// writes it as that child's completion arrives, out of the child's own `completion.json`; one that
1124/// never started a child writes it within the call. A plan `capsule` step waits for its child's
1125/// answer, so it always writes this line within the step. Carries neither the task text nor the
1126/// child's answer — both are the agent's own conversation, which the `tool_call` line for the
1127/// same call already records under the session's `trace.capture` setting.
1128#[derive(Serialize)]
1129struct DelegationEvent {
1130    event_type: &'static str,
1131    event_id: String,
1132    parent_id: Option<String>,
1133    session_id: String,
1134    timestamp: u64,
1135    /// The sub-capsule the agent named, and the exact version it named.
1136    capsule: String,
1137    version: String,
1138    /// The `dlg_` id naming this delegation. `null` on a `refused` outcome, which made none.
1139    delegation_id: Option<String>,
1140    /// The child's own session id, so its trace is findable. `null` when no child ran.
1141    child_session_id: Option<String>,
1142    /// How long the child ran, for a delegation that started; how long the call took, for one that
1143    /// never started.
1144    duration_ms: u64,
1145    /// How the delegation ended. Which vocabulary it is drawn from depends on whether the
1146    /// delegating call waited for the answer itself:
1147    ///
1148    /// - A delegation whose outcome arrived later as a completion reads `ok`, `error`, `crashed`
1149    ///   or `terminated` from [`crate::delegation::DelegationStatus`], or `unknown` when the
1150    ///   completion left no readable file.
1151    /// - One the caller waited out reads `completed` or `timed_out` from
1152    ///   [`crate::delegation_plane::DelegationStatus`].
1153    /// - One that never started reads `failed` or `refused`, from that same enum, either way.
1154    outcome: String,
1155    /// `null` where nothing needs saying. Otherwise the completion's `detail`, or the same
1156    /// sentence the model was given.
1157    reason: Option<String>,
1158}
1159
1160impl DelegationEvent {
1161    /// The one place this record is built, so the two appenders that write it cannot drift into
1162    /// two shapes. Which surface launched the child is not an argument, because it is not a field.
1163    #[allow(clippy::too_many_arguments)]
1164    fn new(
1165        session_id: &str,
1166        session_event_id: &str,
1167        capsule: &str,
1168        version: &str,
1169        delegation_id: Option<String>,
1170        child_session_id: Option<String>,
1171        duration_ms: u64,
1172        outcome: &str,
1173        reason: Option<String>,
1174    ) -> Self {
1175        Self {
1176            event_type: "delegation",
1177            event_id: new_event_id(),
1178            parent_id: Some(session_event_id.to_string()),
1179            session_id: session_id.to_string(),
1180            timestamp: timestamp_ms(),
1181            capsule: capsule.to_string(),
1182            version: version.to_string(),
1183            delegation_id,
1184            child_session_id,
1185            duration_ms,
1186            outcome: outcome.to_string(),
1187            reason,
1188        }
1189    }
1190}
1191
1192// ── TraceWriter impl ─────────────────────────────────────────────────────────
1193
1194impl TraceWriter {
1195    #[allow(clippy::too_many_arguments)]
1196    pub(crate) async fn open(
1197        workdir: &Path,
1198        session_id: String,
1199        capsule_name: String,
1200        capsule_version: String,
1201        model: String,
1202        capabilities: Vec<String>,
1203        effective_grants: ScopeReport,
1204        capture: TraceCapture,
1205        system_prompt: Option<String>,
1206        system_prompt_overridden: bool,
1207        resumed_from: Option<String>,
1208        context_id: Option<String>,
1209        spawned_by: Option<String>,
1210        spawn_delegation_id: Option<String>,
1211    ) -> std::io::Result<Self> {
1212        let file = OpenOptions::new()
1213            .create(true)
1214            .append(true)
1215            .open(workdir.join("trace.jsonl"))
1216            .await?;
1217        // `"cli"` wins over `"none"` when the override cleared the prompt: the operator passed
1218        // `--system-prompt ""`, which is a decision worth recording, not an absence.
1219        let system_prompt_source = match (system_prompt_overridden, system_prompt.is_some()) {
1220            (true, _) => "cli",
1221            (false, true) => "manifest",
1222            (false, false) => "none",
1223        };
1224        let system_prompt_sha256 = system_prompt
1225            .as_ref()
1226            .map(|prompt| murmur_artifact::sha256_hex(prompt.as_bytes()));
1227        let blobs = BlobStore::new(workdir);
1228        // The prompt the operator wrote, kept under the hash `session_start` already carried.
1229        // Written here rather than at `session_start` because a session writes that frame once
1230        // per task and the blob is the same one every time.
1231        if let (true, Some(prompt), Some(sha)) = (
1232            capture.captures_content(),
1233            system_prompt.as_ref(),
1234            system_prompt_sha256.as_deref(),
1235        ) {
1236            blobs.put(sha, prompt.as_bytes()).await?;
1237        }
1238        Ok(Self {
1239            writer: BufWriter::new(file),
1240            session_id,
1241            capsule_name,
1242            capsule_version,
1243            model,
1244            capabilities,
1245            effective_grants,
1246            capture,
1247            blobs,
1248            system_prompt_source,
1249            system_prompt_sha256,
1250            credential_source: "none",
1251            resumed_from,
1252            context_id,
1253            spawned_by,
1254            spawn_delegation_id,
1255            session_start_time: Instant::now(),
1256            session_event_id: new_event_id(),
1257            session_started: false,
1258            session_ended: false,
1259            task_event_id: None,
1260            turn_event_id: None,
1261            total_turns: 0,
1262            total_input_tokens: 0,
1263            total_output_tokens: 0,
1264            total_tool_calls: 0,
1265            total_shell_calls: 0,
1266            task_turns: 0,
1267            task_input_tokens: 0,
1268            task_output_tokens: 0,
1269            task_tool_calls: 0,
1270            task_shell_calls: 0,
1271            task_start_instant: None,
1272            active_task_id: None,
1273            max_session_tokens: None,
1274            machine_tokens_per_day: None,
1275        })
1276    }
1277
1278    /// The spend ceilings this session runs under, written into every later `session_start`.
1279    pub(crate) fn set_spend_ceilings(
1280        &mut self,
1281        max_session_tokens: Option<u64>,
1282        machine_tokens_per_day: Option<u64>,
1283    ) {
1284        self.max_session_tokens = max_session_tokens;
1285        self.machine_tokens_per_day = machine_tokens_per_day;
1286    }
1287
1288    /// The session node of this writer's event tree — the `event_id` its `session_start` will
1289    /// carry, available before that line is written so [`ResourceTraceAppender`] can be opened
1290    /// against the same node.
1291    pub(crate) fn session_event_id(&self) -> &str {
1292        &self.session_event_id
1293    }
1294
1295    /// Whether this session's `inference` records carry content hashes — everything but
1296    /// `trace.capture: none`. The agent loop asks before splitting a payload into a
1297    /// [`WireCapture`], so `none` costs no serialization at all.
1298    pub(crate) fn captures_wire(&self) -> bool {
1299        self.capture.captures_hashes()
1300    }
1301
1302    /// Parent for a launch-scoped event: the session node, or `None` on a writer that has not
1303    /// written `session_start`. A trace line must never name a parent that has no line behind
1304    /// it, and the script-capsule `a2a_send` drain opens a writer over a file with no session
1305    /// frame at all.
1306    fn session_parent(&self) -> Option<String> {
1307        self.session_started.then(|| self.session_event_id.clone())
1308    }
1309
1310    /// Parent for a task-scoped event: the task node, falling back to the session node when no
1311    /// task is active.
1312    fn task_parent(&self) -> Option<String> {
1313        self.task_event_id.clone().or_else(|| self.session_parent())
1314    }
1315
1316    /// Parent for a turn-scoped event: the turn node, falling back to the task node and then to
1317    /// the session node. The fallbacks are what keeps a tool call written outside any turn — a
1318    /// hook's, or the process transport's, before its first `inference` — attached to the tree.
1319    fn turn_parent(&self) -> Option<String> {
1320        self.turn_event_id.clone().or_else(|| self.task_parent())
1321    }
1322
1323    /// Records where the inference credential came from, as
1324    /// [`crate::inference_credential::CredentialSource::trace_name`] names it. `"none"` until set.
1325    pub(crate) fn set_credential_source(&mut self, source: &'static str) {
1326        self.credential_source = source;
1327    }
1328
1329    pub(crate) async fn write_session_start(
1330        &mut self,
1331        max_turns: u32,
1332        tools_declared: Vec<String>,
1333    ) -> std::io::Result<()> {
1334        let event = SessionStartEvent {
1335            event_type: "session_start",
1336            event_id: self.session_event_id.clone(),
1337            parent_id: None,
1338            session_id: self.session_id.clone(),
1339            timestamp: timestamp_ms(),
1340            capsule_name: self.capsule_name.clone(),
1341            capsule_version: self.capsule_version.clone(),
1342            model: self.model.clone(),
1343            max_turns,
1344            max_session_tokens: self.max_session_tokens,
1345            machine_tokens_per_day: self.machine_tokens_per_day,
1346            capabilities: self.capabilities.clone(),
1347            tools_declared,
1348            containment_declared: self.effective_grants.declared_containment,
1349            containment_achieved: self.effective_grants.achieved_containment,
1350            workdir_exec: self.effective_grants.workdir_exec,
1351            userns_grant: self.effective_grants.userns_grant,
1352            effective_grants: self.effective_grants.clone(),
1353            system_prompt_source: self.system_prompt_source,
1354            system_prompt_sha256: self.system_prompt_sha256.clone(),
1355            credential_source: self.credential_source,
1356            resumed_from: self.resumed_from.clone(),
1357            context_id: self.context_id.clone(),
1358            spawned_by: self.spawned_by.clone(),
1359            delegation_id: self.spawn_delegation_id.clone(),
1360        };
1361        self.write_event(&event).await?;
1362        self.session_started = true;
1363        Ok(())
1364    }
1365
1366    /// `input_tokens`/`output_tokens` are the runtime's tiktoken estimates and are what the
1367    /// session and task totals accumulate; `usage` is the provider's own report for the same
1368    /// call, written verbatim beside them and accumulated into nothing.
1369    #[allow(clippy::too_many_arguments)]
1370    pub(crate) async fn write_inference(
1371        &mut self,
1372        turn: u32,
1373        input_tokens: u64,
1374        output_tokens: u64,
1375        decision: String,
1376        stop_reason: Option<&str>,
1377        tool_name: Option<String>,
1378        origin: Option<&InferenceOrigin>,
1379        usage: Option<&DriverUsage>,
1380        message_ids: Vec<String>,
1381        wire: Option<&WireCapture>,
1382    ) -> std::io::Result<()> {
1383        let event_id = new_event_id();
1384        // The agent loop's own inference *is* the turn node — there is no separate turn line —
1385        // so it hangs off the task and everything the turn goes on to produce hangs off it. A
1386        // hook's `run-inference` (`origin: Some(_)`) is one of those products, not a new turn.
1387        let parent_id = if origin.is_none() {
1388            let parent = self.task_parent();
1389            self.turn_event_id = Some(event_id.clone());
1390            parent
1391        } else {
1392            self.turn_parent()
1393        };
1394        let (system_sha, tools_sha, response_sha, message_shas) =
1395            match wire.filter(|_| self.capture.captures_hashes()) {
1396                Some(wire) => self.record_wire(wire).await?,
1397                None => (None, None, None, Vec::new()),
1398            };
1399        let event = InferenceEvent {
1400            event_type: "inference",
1401            event_id,
1402            parent_id,
1403            session_id: self.session_id.clone(),
1404            timestamp: timestamp_ms(),
1405            turn,
1406            task_id: self.active_task_id.clone(),
1407            input_tokens,
1408            output_tokens,
1409            decision,
1410            stop_reason: stop_reason.map(str::to_string),
1411            tool_name,
1412            input_tokens_actual: usage.and_then(|u| u.input_tokens),
1413            output_tokens_actual: usage.and_then(|u| u.output_tokens),
1414            cached_tokens: usage.and_then(|u| u.cached_tokens),
1415            cache_write_tokens: usage.and_then(|u| u.cache_write_tokens),
1416            origin: origin.map(|o| o.source.clone()),
1417            model: origin.map(|o| o.model.clone()),
1418            message_ids,
1419            system_sha,
1420            tools_sha,
1421            response_sha,
1422            message_shas,
1423        };
1424        self.write_event(&event).await?;
1425        self.total_turns = self.total_turns.saturating_add(1);
1426        self.total_input_tokens = self.total_input_tokens.saturating_add(input_tokens);
1427        self.total_output_tokens = self.total_output_tokens.saturating_add(output_tokens);
1428        self.task_turns = self.task_turns.saturating_add(1);
1429        self.task_input_tokens = self.task_input_tokens.saturating_add(input_tokens);
1430        self.task_output_tokens = self.task_output_tokens.saturating_add(output_tokens);
1431        Ok(())
1432    }
1433
1434    /// Hash the four bodies of one request and, under [`TraceCapture::Content`], store each
1435    /// behind its hash.
1436    ///
1437    /// The hash is computed in every capture mode that reaches here; only the blob write is
1438    /// gated, so `meta` and `content` name the same bytes and differ solely in whether those
1439    /// bytes are also on disk.
1440    async fn record_wire(
1441        &mut self,
1442        wire: &WireCapture,
1443    ) -> std::io::Result<(Option<String>, Option<String>, Option<String>, Vec<String>)> {
1444        let system_sha = murmur_artifact::sha256_hex(&wire.system);
1445        let tools_sha = murmur_artifact::sha256_hex(&wire.tools);
1446        let response_sha = murmur_artifact::sha256_hex(&wire.response);
1447        let message_shas: Vec<String> = wire
1448            .messages
1449            .iter()
1450            .map(|message| murmur_artifact::sha256_hex(message))
1451            .collect();
1452
1453        if self.capture.captures_content() {
1454            // Verbatim, unredacted. `write_tool_call` redacts peer handles out of its own summary
1455            // fields; a blob is not a summary, and redacting one would make its hash name bytes
1456            // no file holds — destroying the only property this store exists to provide.
1457            self.blobs.put(&system_sha, &wire.system).await?;
1458            self.blobs.put(&tools_sha, &wire.tools).await?;
1459            self.blobs.put(&response_sha, &wire.response).await?;
1460            for (sha, body) in message_shas.iter().zip(&wire.messages) {
1461                self.blobs.put(sha, body).await?;
1462            }
1463        }
1464
1465        Ok((
1466            Some(system_sha),
1467            Some(tools_sha),
1468            Some(response_sha),
1469            message_shas,
1470        ))
1471    }
1472
1473    #[allow(clippy::too_many_arguments)]
1474    pub(crate) async fn write_tool_call(
1475        &mut self,
1476        turn: u32,
1477        tool_name: String,
1478        tool_call_id: Option<String>,
1479        mut input: Value,
1480        input_bytes: u64,
1481        output: &str,
1482        output_bytes: u64,
1483        duration_ms: u64,
1484        status: String,
1485        state_effect: Option<String>,
1486        resource_id: Option<String>,
1487    ) -> std::io::Result<()> {
1488        // A peer handle reaches this event by an ordinary route — it is an argument the model
1489        // passed to `fetch-peer-file`, and every call's arguments are recorded. It is also a
1490        // credential, and the trace is durable, so the two must not meet: the `handle_id` goes in
1491        // instead, which is what correlates the record with the mint and the redeem anyway.
1492        crate::peer_handoff::redact_handles_in_json(&mut input);
1493        let event = ToolCallEvent {
1494            event_type: "tool_call",
1495            event_id: new_event_id(),
1496            parent_id: self.turn_parent(),
1497            session_id: self.session_id.clone(),
1498            timestamp: timestamp_ms(),
1499            turn,
1500            task_id: self.active_task_id.clone(),
1501            tool_name,
1502            // An absent or empty provider id records as `null`: `""` would read as an id the
1503            // provider actually issued.
1504            tool_call_id: tool_call_id.filter(|id| !id.is_empty()),
1505            input,
1506            input_bytes,
1507            output: self
1508                .capture
1509                .captures_content()
1510                .then(|| crate::peer_handoff::redact_handle_tokens(output).into_owned()),
1511            output_bytes,
1512            duration_ms,
1513            status,
1514            state_effect,
1515            resource_id,
1516        };
1517        self.write_event(&event).await?;
1518        self.total_tool_calls = self.total_tool_calls.saturating_add(1);
1519        self.task_tool_calls = self.task_tool_calls.saturating_add(1);
1520        Ok(())
1521    }
1522
1523    /// Records a skill invocation as a `skill_call` event. Skill calls are NOT counted in
1524    /// `total_tool_calls` — they appear in a separate `── Skill calls ──` section in `mur trace show`.
1525    pub(crate) async fn write_skill_call(
1526        &mut self,
1527        turn: u32,
1528        skill_name: String,
1529        output_bytes: u64,
1530        duration_ms: u64,
1531        status: String,
1532    ) -> std::io::Result<()> {
1533        let event = SkillCallEvent {
1534            event_type: "skill_call",
1535            event_id: new_event_id(),
1536            parent_id: self.turn_parent(),
1537            session_id: self.session_id.clone(),
1538            timestamp: timestamp_ms(),
1539            turn,
1540            task_id: self.active_task_id.clone(),
1541            skill_name,
1542            output_bytes,
1543            duration_ms,
1544            status,
1545        };
1546        self.write_event(&event).await
1547        // Intentionally does not increment total_tool_calls or task_tool_calls.
1548    }
1549
1550    // One positional parameter per JSONL column, as every other `write_*` here does; the
1551    // sibling `write_tool_call` carries the same allowance.
1552    #[allow(clippy::too_many_arguments)]
1553    pub(crate) async fn write_shell(
1554        &mut self,
1555        turn: u32,
1556        binary: String,
1557        command: String,
1558        exit_code: i32,
1559        stdout_bytes: u64,
1560        stderr_bytes: u64,
1561        duration_ms: u64,
1562        resource_limit: Option<String>,
1563    ) -> std::io::Result<()> {
1564        let event = ShellEvent {
1565            event_type: "shell",
1566            event_id: new_event_id(),
1567            parent_id: self.turn_parent(),
1568            session_id: self.session_id.clone(),
1569            timestamp: timestamp_ms(),
1570            turn,
1571            task_id: self.active_task_id.clone(),
1572            binary,
1573            command,
1574            exit_code,
1575            stdout_bytes,
1576            stderr_bytes,
1577            duration_ms,
1578            resource_limit,
1579        };
1580        self.write_event(&event).await?;
1581        self.total_shell_calls = self.total_shell_calls.saturating_add(1);
1582        self.task_shell_calls = self.task_shell_calls.saturating_add(1);
1583        Ok(())
1584    }
1585
1586    /// A command demoted to the background, written from the agent loop at the moment of
1587    /// demotion.
1588    ///
1589    /// Counts as this session's shell call, exactly as a foreground one does — the pair with
1590    /// [`Self::write_shell_completed`], which deliberately does not count, so `mur trace show`
1591    /// reports each shell command once whichever way it ran.
1592    pub(crate) async fn write_shell_detached(
1593        &mut self,
1594        turn: u32,
1595        work_id: &str,
1596        binary: &str,
1597        command: &str,
1598        grace_ms: u64,
1599    ) -> std::io::Result<()> {
1600        let event = ShellDetachedEvent {
1601            event_type: "shell_detached",
1602            event_id: new_event_id(),
1603            parent_id: self.turn_parent(),
1604            session_id: self.session_id.clone(),
1605            timestamp: timestamp_ms(),
1606            turn,
1607            task_id: self.active_task_id.clone(),
1608            work_id: work_id.to_string(),
1609            binary: binary.to_string(),
1610            command: command.to_string(),
1611            grace_ms,
1612        };
1613        self.write_event(&event).await?;
1614        self.total_shell_calls = self.total_shell_calls.saturating_add(1);
1615        self.task_shell_calls = self.task_shell_calls.saturating_add(1);
1616        Ok(())
1617    }
1618
1619    /// A detached command finishing, written by the task loop as it turns the completion into a
1620    /// task. `completion_task_id` is that task's id, so the two records join.
1621    #[allow(clippy::too_many_arguments)]
1622    pub(crate) async fn write_shell_completed(
1623        &mut self,
1624        work_id: &str,
1625        binary: &str,
1626        command: &str,
1627        exit_code: i32,
1628        duration_ms: u64,
1629        output_path: &str,
1630        output_bytes: u64,
1631        resource_limit: Option<String>,
1632        status: &str,
1633        completion_task_id: &str,
1634    ) -> std::io::Result<()> {
1635        let event = ShellCompletedEvent {
1636            event_type: "shell_completed",
1637            event_id: new_event_id(),
1638            parent_id: self.session_parent(),
1639            session_id: self.session_id.clone(),
1640            timestamp: timestamp_ms(),
1641            work_id: work_id.to_string(),
1642            binary: binary.to_string(),
1643            command: command.to_string(),
1644            exit_code,
1645            duration_ms,
1646            output_path: output_path.to_string(),
1647            output_bytes,
1648            resource_limit,
1649            status: status.to_string(),
1650            completion_task_id: completion_task_id.to_string(),
1651        };
1652        self.write_event(&event).await
1653    }
1654
1655    /// A detached command the session ended without waiting for.
1656    ///
1657    /// `recovered` carries the exit code, the output path and that file's byte count for a command
1658    /// that finished during teardown, and is `None` for one still running. The three travel as one
1659    /// argument because they exist together: a command with an exit code has a written log, and
1660    /// one without has neither.
1661    pub(crate) async fn write_shell_abandoned(
1662        &mut self,
1663        work_id: &str,
1664        binary: &str,
1665        command: &str,
1666        running_ms: u64,
1667        recovered: Option<(i32, &str, u64)>,
1668    ) -> std::io::Result<()> {
1669        let event = ShellAbandonedEvent {
1670            event_type: "shell_abandoned",
1671            event_id: new_event_id(),
1672            parent_id: self.session_parent(),
1673            session_id: self.session_id.clone(),
1674            timestamp: timestamp_ms(),
1675            work_id: work_id.to_string(),
1676            binary: binary.to_string(),
1677            command: command.to_string(),
1678            running_ms,
1679            exit_code: recovered.map(|(exit_code, _, _)| exit_code),
1680            output_path: recovered.map(|(_, path, _)| path.to_string()),
1681            output_bytes: recovered.map(|(_, _, bytes)| bytes),
1682        };
1683        self.write_event(&event).await
1684    }
1685
1686    /// A demotion the trace could not record, written where the failure was seen.
1687    pub(crate) async fn write_shell_detach_unrecorded(
1688        &mut self,
1689        turn: u32,
1690        work_id: &str,
1691        binary: &str,
1692        reason: &str,
1693    ) -> std::io::Result<()> {
1694        let event = ShellDetachUnrecordedEvent {
1695            event_type: "shell_detach_unrecorded",
1696            event_id: new_event_id(),
1697            parent_id: self.turn_parent(),
1698            session_id: self.session_id.clone(),
1699            timestamp: timestamp_ms(),
1700            turn,
1701            task_id: self.active_task_id.clone(),
1702            work_id: work_id.to_string(),
1703            binary: binary.to_string(),
1704            reason: reason.to_string(),
1705        };
1706        self.write_event(&event).await
1707    }
1708
1709    pub(crate) async fn write_a2a_task_received(
1710        &mut self,
1711        task_id: &str,
1712        context_id: &str,
1713        message_id: &str,
1714        traceparent_from_caller: Option<&str>,
1715    ) -> std::io::Result<()> {
1716        let event = A2aTaskReceivedEvent {
1717            event_type: "a2a_task_received",
1718            event_id: new_event_id(),
1719            parent_id: self.session_parent(),
1720            session_id: self.session_id.clone(),
1721            timestamp: timestamp_ms(),
1722            task_id: task_id.to_string(),
1723            context_id: context_id.to_string(),
1724            message_id: message_id.to_string(),
1725            traceparent_from_caller: traceparent_from_caller.map(str::to_string),
1726        };
1727        self.write_event(&event).await
1728    }
1729
1730    pub(crate) async fn write_a2a_send(
1731        &mut self,
1732        peer_url: &str,
1733        message_id: &str,
1734        task_id: &str,
1735        context_id: &str,
1736        traceparent: Option<&str>,
1737        trust: TrustClass,
1738    ) -> std::io::Result<()> {
1739        let event = A2aSendEvent {
1740            event_type: "a2a_send",
1741            event_id: new_event_id(),
1742            parent_id: self.session_parent(),
1743            session_id: self.session_id.clone(),
1744            timestamp: timestamp_ms(),
1745            peer_url: peer_url.to_string(),
1746            message_id: message_id.to_string(),
1747            task_id: task_id.to_string(),
1748            context_id: context_id.to_string(),
1749            traceparent: traceparent.map(str::to_string),
1750            trust: trust.as_str().to_string(),
1751        };
1752        self.write_event(&event).await
1753    }
1754
1755    pub(crate) async fn write_task_start(
1756        &mut self,
1757        task_id: &str,
1758        context_id: &str,
1759        source: &str,
1760        provenance: TaskProvenance,
1761        delegation_id: Option<&str>,
1762        message_parts_bytes: u64,
1763    ) -> std::io::Result<()> {
1764        // Reset per-task counters for this task
1765        self.task_turns = 0;
1766        self.task_input_tokens = 0;
1767        self.task_output_tokens = 0;
1768        self.task_tool_calls = 0;
1769        self.task_shell_calls = 0;
1770        self.task_start_instant = Some(Instant::now());
1771        self.active_task_id = Some(task_id.to_string());
1772
1773        let event_id = new_event_id();
1774        self.task_event_id = Some(event_id.clone());
1775        self.turn_event_id = None;
1776
1777        let event = TaskStartEvent {
1778            event_type: "task_start",
1779            event_id: event_id.clone(),
1780            parent_id: self.session_parent(),
1781            session_id: self.session_id.clone(),
1782            timestamp: timestamp_ms(),
1783            task_id: task_id.to_string(),
1784            context_id: context_id.to_string(),
1785            source: source.to_string(),
1786            origin: provenance.origin().as_str().to_string(),
1787            trust: provenance.trust().as_str().to_string(),
1788            // One rule for every call site, so the recorded lane cannot disagree with the lane
1789            // `LaneQueue` filed the task under — both read the same provenance.
1790            lane: TaskLane::for_origin(provenance.origin())
1791                .as_str()
1792                .to_string(),
1793            delegation_id: delegation_id.map(str::to_string),
1794            message_parts_bytes,
1795        };
1796        self.write_event(&event).await
1797    }
1798
1799    pub(crate) async fn write_task_end(
1800        &mut self,
1801        task_id: &str,
1802        exit_status: &str,
1803        reopen_count: u32,
1804    ) -> std::io::Result<()> {
1805        let duration_ms = self
1806            .task_start_instant
1807            .take()
1808            .map(|t| t.elapsed().as_millis().try_into().unwrap_or(u64::MAX))
1809            .unwrap_or(0);
1810
1811        let event = TaskEndEvent {
1812            event_type: "task_end",
1813            event_id: new_event_id(),
1814            parent_id: self.task_parent(),
1815            session_id: self.session_id.clone(),
1816            timestamp: timestamp_ms(),
1817            task_id: task_id.to_string(),
1818            exit_status: exit_status.to_string(),
1819            duration_ms,
1820            turns: self.task_turns,
1821            input_tokens: self.task_input_tokens,
1822            output_tokens: self.task_output_tokens,
1823            tool_calls: self.task_tool_calls,
1824            shell_calls: self.task_shell_calls,
1825            reopen_count,
1826        };
1827        self.active_task_id = None;
1828        self.task_event_id = None;
1829        self.turn_event_id = None;
1830        self.write_event(&event).await
1831    }
1832
1833    /// Record that a person stopped this task, at the point the loop stopped.
1834    ///
1835    /// Leaves `active_task_id` and the task's parent id alone: for a running task the terminal
1836    /// `task_end` still follows and closes the frame. A task cancelled while queued was never
1837    /// started, so this record hangs off the session.
1838    pub(crate) async fn write_task_canceled(
1839        &mut self,
1840        task_id: &str,
1841        turn: Option<u32>,
1842        phase: &str,
1843        detached_work_ids: Vec<String>,
1844        delegation_ids: Vec<String>,
1845    ) -> std::io::Result<()> {
1846        let event = TaskCanceledEvent {
1847            event_type: "task_canceled",
1848            event_id: new_event_id(),
1849            parent_id: self.task_parent(),
1850            session_id: self.session_id.clone(),
1851            timestamp: timestamp_ms(),
1852            task_id: task_id.to_string(),
1853            turn,
1854            phase: phase.to_string(),
1855            detached_work_ids,
1856            delegation_ids,
1857        };
1858        self.write_event(&event).await
1859    }
1860
1861    /// Record that an `on-task-end` hook reopened the task. Written by the runtime's
1862    /// per-task reopen loop between two agent-loop attempts, once per reopen, before
1863    /// the terminal `task_end` record. `reopen_number` is 1-based.
1864    pub(crate) async fn write_task_reopened(
1865        &mut self,
1866        task_id: &str,
1867        hook_name: &str,
1868        reason: &str,
1869        reopen_number: u32,
1870    ) -> std::io::Result<()> {
1871        let event = TaskReopenedEvent {
1872            event_type: "task_reopened",
1873            event_id: new_event_id(),
1874            parent_id: self.task_parent(),
1875            session_id: self.session_id.clone(),
1876            timestamp: timestamp_ms(),
1877            task_id: task_id.to_string(),
1878            hook_name: hook_name.to_string(),
1879            reason: reason.to_string(),
1880            reopen_number,
1881        };
1882        self.write_event(&event).await
1883    }
1884
1885    /// Cumulative turns consumed by the active task so far, across however many
1886    /// agent-loop attempts have run since the last [`Self::write_task_start`]. Read by
1887    /// the reopen loop to compute the remaining `max_turns` budget for the next attempt
1888    /// so reopening never grants turns past the capsule's ceiling.
1889    pub(crate) fn task_turns(&self) -> u32 {
1890        self.task_turns
1891    }
1892
1893    pub(crate) async fn write_compaction(
1894        &mut self,
1895        turn: u32,
1896        tokens_before: u64,
1897        tokens_after: u64,
1898    ) -> std::io::Result<()> {
1899        let event = CompactionEvent {
1900            event_type: "compaction",
1901            event_id: new_event_id(),
1902            parent_id: self.turn_parent(),
1903            session_id: self.session_id.clone(),
1904            timestamp: timestamp_ms(),
1905            turn,
1906            task_id: self.active_task_id.clone(),
1907            tokens_before,
1908            tokens_after,
1909        };
1910        self.write_event(&event).await
1911    }
1912
1913    /// Record that compaction was attempted and declined, leaving the context over budget.
1914    /// `tokens` is the occupancy at the moment of the decline; `reason` is
1915    /// [`COMPACTION_DECLINED_NO_HOOK_REPLACEMENT`] or
1916    /// [`COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL`].
1917    pub(crate) async fn write_compaction_declined(
1918        &mut self,
1919        turn: u32,
1920        tokens: u64,
1921        reason: &str,
1922    ) -> std::io::Result<()> {
1923        let event = CompactionDeclinedEvent {
1924            event_type: "compaction_declined",
1925            event_id: new_event_id(),
1926            parent_id: self.turn_parent(),
1927            session_id: self.session_id.clone(),
1928            timestamp: timestamp_ms(),
1929            turn,
1930            task_id: self.active_task_id.clone(),
1931            tokens,
1932            reason: reason.to_string(),
1933        };
1934        self.write_event(&event).await
1935    }
1936
1937    /// Record what the runtime did with an `on-task-start` hook's `seed-context`.
1938    ///
1939    /// Written for every outcome, committed or not: `tokens` is what reached the context
1940    /// (`0` on a rejection), `proposed_tokens` is what the hook returned, and
1941    /// `budget_tokens` is the ceiling it was measured against. `reason` is `Some` only for
1942    /// [`SEED_OUTCOME_REJECTED`]. Task-scoped, like `task_start`/`task_end`.
1943    #[allow(clippy::too_many_arguments)]
1944    pub(crate) async fn write_context_seed(
1945        &mut self,
1946        hook_name: &str,
1947        tokens: u64,
1948        proposed_tokens: u64,
1949        budget_tokens: u64,
1950        outcome: &str,
1951        reason: Option<&str>,
1952        message_ids: Vec<String>,
1953    ) -> std::io::Result<()> {
1954        let event = ContextSeedEvent {
1955            event_type: "context_seed",
1956            event_id: new_event_id(),
1957            parent_id: self.task_parent(),
1958            session_id: self.session_id.clone(),
1959            timestamp: timestamp_ms(),
1960            task_id: self.active_task_id.clone(),
1961            hook_name: hook_name.to_string(),
1962            tokens,
1963            proposed_tokens,
1964            budget_tokens,
1965            outcome: outcome.to_string(),
1966            reason: reason.map(str::to_string),
1967            message_ids,
1968        };
1969        self.write_event(&event).await
1970    }
1971
1972    /// Record one store's deletion, naming the reason and everything that went.
1973    ///
1974    /// Session-scoped: `parent_id` is the session node, so the deletion hangs off the launch that
1975    /// performed it rather than off whatever task happened to be open. `messages_dropped` is
1976    /// `Some` only for [`RETENTION_REASON_MAX_MESSAGES`].
1977    pub(crate) async fn write_retention(
1978        &mut self,
1979        store: &str,
1980        reason: &str,
1981        targets: Vec<String>,
1982        messages_dropped: Option<u64>,
1983    ) -> std::io::Result<()> {
1984        let event = RetentionEvent {
1985            event_type: "retention",
1986            event_id: new_event_id(),
1987            parent_id: self.session_parent(),
1988            session_id: self.session_id.clone(),
1989            timestamp: timestamp_ms(),
1990            store: store.to_string(),
1991            reason: reason.to_string(),
1992            removed: u32::try_from(targets.len()).unwrap_or(u32::MAX),
1993            targets,
1994            messages_dropped,
1995        };
1996        self.write_event(&event).await
1997    }
1998
1999    /// Record that a policy hook refused a call before it ran. The only account of a call the
2000    /// model asked for and never got: nothing ran, so no `tool_call` or `shell` record
2001    /// accompanies it.
2002    pub(crate) async fn write_call_denied(
2003        &mut self,
2004        turn: u32,
2005        event: &str,
2006        hook_name: &str,
2007        target: &str,
2008        reason: &str,
2009    ) -> std::io::Result<()> {
2010        let event = CallDeniedEvent {
2011            event_type: "call_denied",
2012            event_id: new_event_id(),
2013            parent_id: self.turn_parent(),
2014            session_id: self.session_id.clone(),
2015            timestamp: timestamp_ms(),
2016            turn,
2017            event: event.to_string(),
2018            hook_name: hook_name.to_string(),
2019            target: target.to_string(),
2020            reason: reason.to_string(),
2021        };
2022        self.write_event(&event).await
2023    }
2024
2025    /// Record that a spend ceiling refused a driver call before it was sent. `origin` is the
2026    /// `hook:<name>` of a hook's `run-inference` call, `None` for an agent-loop turn.
2027    pub(crate) async fn write_spend_ceiling_reached(
2028        &mut self,
2029        turn: u32,
2030        refusal: &crate::spend::SpendRefusal,
2031        origin: Option<&str>,
2032    ) -> std::io::Result<()> {
2033        let event = SpendCeilingReachedEvent {
2034            event_type: "spend_ceiling_reached",
2035            event_id: new_event_id(),
2036            parent_id: self.turn_parent(),
2037            session_id: self.session_id.clone(),
2038            timestamp: timestamp_ms(),
2039            turn,
2040            task_id: self.active_task_id.clone(),
2041            refusal,
2042            origin,
2043        };
2044        self.write_event(&event).await
2045    }
2046
2047    /// Record that the manifest's `capabilities.filesystem.read_only` refused a call before it
2048    /// ran. Like [`Self::write_call_denied`] it is the only account of a call the model asked for
2049    /// and never got — nothing ran, so no `tool_call` or `shell` record accompanies it.
2050    #[allow(clippy::too_many_arguments)]
2051    pub(crate) async fn write_protected_path_denied(
2052        &mut self,
2053        turn: u32,
2054        call: &str,
2055        target: &str,
2056        path: &str,
2057        rule: &str,
2058        signal: &str,
2059        reason: &str,
2060    ) -> std::io::Result<()> {
2061        let event = ProtectedPathDeniedEvent {
2062            event_type: "protected_path_denied",
2063            event_id: new_event_id(),
2064            parent_id: self.turn_parent(),
2065            session_id: self.session_id.clone(),
2066            timestamp: timestamp_ms(),
2067            turn,
2068            call: call.to_string(),
2069            target: target.to_string(),
2070            path: path.to_string(),
2071            rule: rule.to_string(),
2072            signal: signal.to_string(),
2073            reason: reason.to_string(),
2074        };
2075        self.write_event(&event).await
2076    }
2077
2078    /// Record that a hook returned a `hook-output` arm the lifecycle event does not
2079    /// honor. Written by the agent loop from a buffered [`crate::hooks::DispatchFault`]
2080    /// drained just before `session_end`. Non-fatal: the session already continued as
2081    /// if the hook had returned `none`; this only makes the discard visible to
2082    /// `mur trace show` and anything reading the session trace.
2083    pub(crate) async fn write_hook_dispatch_error(
2084        &mut self,
2085        hook_name: &str,
2086        event: &str,
2087        arm: &str,
2088    ) -> std::io::Result<()> {
2089        let event = HookDispatchErrorEvent {
2090            event_type: "hook_dispatch_error",
2091            event_id: new_event_id(),
2092            parent_id: self.session_parent(),
2093            session_id: self.session_id.clone(),
2094            timestamp: timestamp_ms(),
2095            hook_name: hook_name.to_string(),
2096            event: event.to_string(),
2097            arm: arm.to_string(),
2098        };
2099        self.write_event(&event).await
2100    }
2101
2102    pub(crate) async fn write_session_end(&mut self, exit_status: &str) -> std::io::Result<()> {
2103        let duration_ms = self
2104            .session_start_time
2105            .elapsed()
2106            .as_millis()
2107            .try_into()
2108            .unwrap_or(u64::MAX);
2109        let event = SessionEndEvent {
2110            event_type: "session_end",
2111            event_id: new_event_id(),
2112            parent_id: self.session_parent(),
2113            session_id: self.session_id.clone(),
2114            timestamp: timestamp_ms(),
2115            total_turns: self.total_turns,
2116            total_input_tokens: self.total_input_tokens,
2117            total_output_tokens: self.total_output_tokens,
2118            total_tool_calls: self.total_tool_calls,
2119            total_shell_calls: self.total_shell_calls,
2120            duration_ms,
2121            exit_status: exit_status.to_string(),
2122        };
2123        self.write_event(&event).await?;
2124        self.session_ended = true;
2125        Ok(())
2126    }
2127
2128    /// Writes session_end with the accumulated totals if session_start was written but
2129    /// session_end was not (error exit paths that bypass the normal Ok() return sites).
2130    pub(crate) async fn write_session_end_if_not_ended(
2131        &mut self,
2132        exit_status: &str,
2133    ) -> std::io::Result<()> {
2134        if self.session_started && !self.session_ended {
2135            self.write_session_end(exit_status).await?;
2136        }
2137        Ok(())
2138    }
2139
2140    pub(crate) async fn flush(&mut self) -> std::io::Result<()> {
2141        self.writer.flush().await
2142    }
2143
2144    /// Writes one event as one line, and flushes.
2145    ///
2146    /// The flush is not an optimisation choice: [`ResourceTraceAppender`] appends to this same
2147    /// `trace.jsonl` while a task runs, so a line left half-buffered here would be split around
2148    /// whatever the appender wrote in between and the file would stop parsing as JSONL. The
2149    /// two writers agree on one rule — a complete line per write, then flush — and that rule is
2150    /// what makes concurrent reads during a running task safe to record.
2151    async fn write_event(&mut self, event: &impl Serialize) -> std::io::Result<()> {
2152        let mut line = serde_json::to_string(event).map_err(std::io::Error::other)?;
2153        line.push('\n');
2154        self.writer.write_all(line.as_bytes()).await?;
2155        self.writer.flush().await
2156    }
2157}
2158
2159/// `inference_credential`: a rotation, rejection or unreadable source for the session's inference
2160/// credential. `trigger` is written only with `change: "rotated"`, `status` and `retried` only with
2161/// `"rejected"`, `reason` only with `"unreadable"`, and `credential` whenever the manifest named one.
2162#[derive(Serialize)]
2163struct InferenceCredentialEvent<'a> {
2164    event_type: &'static str,
2165    event_id: String,
2166    parent_id: Option<String>,
2167    session_id: String,
2168    timestamp: u64,
2169    source: &'static str,
2170    #[serde(skip_serializing_if = "Option::is_none")]
2171    credential: Option<&'a str>,
2172    change: &'static str,
2173    #[serde(skip_serializing_if = "Option::is_none")]
2174    trigger: Option<&'static str>,
2175    #[serde(skip_serializing_if = "Option::is_none")]
2176    status: Option<u16>,
2177    #[serde(skip_serializing_if = "Option::is_none")]
2178    retried: Option<bool>,
2179    #[serde(skip_serializing_if = "Option::is_none")]
2180    reason: Option<&'static str>,
2181}
2182
2183// ── Resource-plane appender ───────────────────────────────────────────────────
2184
2185/// A second, independent `O_APPEND` handle to the session's `trace.jsonl`, used by the resource
2186/// plane, by the peer plane's listener, and by the peer-handoff tools in the agent loop.
2187///
2188/// [`TraceWriter`] is `&mut`-owned by the agent loop and cannot be shared, and the motivating
2189/// case for a resource-plane event — a gateway reading a finished-but-alive capsule — happens
2190/// after `session_end` has already been written. So the plane gets its own handle rather than a
2191/// borrow of the loop's, and every event is written at the moment of the request rather than
2192/// deferred to a task boundary: a denied read that only surfaced at the next task end would be a
2193/// record of the wrong thing at the wrong time.
2194///
2195/// Interleaving is safe because both writers emit exactly one complete line per `write_all` and
2196/// flush it, and `O_APPEND` makes each such write atomic against the other's.
2197pub struct ResourceTraceAppender {
2198    /// Async rather than a `std::sync::Mutex`: this is held across an `await` on the write.
2199    file: tokio::sync::Mutex<File>,
2200    session_id: String,
2201    /// The session node every line this appender writes hangs off — [`TraceWriter`]'s own
2202    /// [`TraceWriter::session_event_id`], handed over at open time. The plane has no turn or
2203    /// task of its own: a read served after `session_end` belongs to the launch, not to
2204    /// whatever task happened to run last.
2205    session_event_id: String,
2206}
2207
2208impl ResourceTraceAppender {
2209    pub async fn open(
2210        workdir: &Path,
2211        session_id: String,
2212        session_event_id: String,
2213    ) -> std::io::Result<Self> {
2214        let file = OpenOptions::new()
2215            .create(true)
2216            .append(true)
2217            .open(workdir.join("trace.jsonl"))
2218            .await?;
2219        Ok(Self {
2220            file: tokio::sync::Mutex::new(file),
2221            session_id,
2222            session_event_id,
2223        })
2224    }
2225
2226    /// Records one `list`. Failures are swallowed: a trace that cannot be written must not turn
2227    /// a served read into an error, and the alternative — refusing the read — would let anyone
2228    /// who can fill the disk take the plane down.
2229    #[allow(clippy::too_many_arguments)]
2230    pub(crate) async fn write_resource_list(
2231        &self,
2232        root: &str,
2233        entry_count: usize,
2234        total_bytes: u64,
2235        generation: u64,
2236        containment_achieved: ContainmentClass,
2237        outcome: &str,
2238        reason: Option<String>,
2239    ) {
2240        let event = ResourceListEvent {
2241            event_type: "resource_list",
2242            event_id: new_event_id(),
2243            parent_id: Some(self.session_event_id.clone()),
2244            session_id: self.session_id.clone(),
2245            timestamp: timestamp_ms(),
2246            root: root.to_string(),
2247            entry_count,
2248            total_bytes,
2249            generation,
2250            containment_achieved,
2251            outcome: outcome.to_string(),
2252            reason,
2253        };
2254        self.append(&event).await;
2255    }
2256
2257    /// Records one `read`, served or refused. `bytes` and `sha256` are `None` on every
2258    /// non-`ok` outcome — a refusal must not carry a hash of something it did not serve.
2259    #[allow(clippy::too_many_arguments)]
2260    pub(crate) async fn write_resource_read(
2261        &self,
2262        path: &str,
2263        outcome: &str,
2264        bytes: Option<u64>,
2265        sha256: Option<String>,
2266        generation: u64,
2267        containment_achieved: ContainmentClass,
2268        reason: Option<String>,
2269    ) {
2270        let event = ResourceReadEvent {
2271            event_type: "resource_read",
2272            event_id: new_event_id(),
2273            parent_id: Some(self.session_event_id.clone()),
2274            session_id: self.session_id.clone(),
2275            timestamp: timestamp_ms(),
2276            path: path.to_string(),
2277            outcome: outcome.to_string(),
2278            bytes,
2279            sha256,
2280            generation,
2281            containment_achieved,
2282            reason,
2283        };
2284        self.append(&event).await;
2285    }
2286
2287    /// Records one mint. `handle_id` and `expires_at_ms` are `None` on every non-`ok` outcome —
2288    /// a refused mint produced no token, and an audit record must not imply one exists.
2289    #[allow(clippy::too_many_arguments)]
2290    pub(crate) async fn write_peer_handle_mint(
2291        &self,
2292        handle_id: Option<String>,
2293        path: &str,
2294        audience: &str,
2295        expires_at_ms: Option<u64>,
2296        outcome: &str,
2297        reason: Option<String>,
2298    ) {
2299        let event = PeerHandleMintEvent {
2300            event_type: "peer_handle_mint",
2301            event_id: new_event_id(),
2302            parent_id: Some(self.session_event_id.clone()),
2303            session_id: self.session_id.clone(),
2304            timestamp: timestamp_ms(),
2305            handle_id,
2306            path: path.to_string(),
2307            audience: audience.to_string(),
2308            expires_at_ms,
2309            outcome: outcome.to_string(),
2310            reason,
2311        };
2312        self.append(&event).await;
2313    }
2314
2315    /// Records one redeem, served or refused. The token itself is never written — only its
2316    /// `handle_id`.
2317    #[allow(clippy::too_many_arguments)]
2318    pub(crate) async fn write_peer_handle_redeem(
2319        &self,
2320        handle_id: &str,
2321        path: Option<String>,
2322        generation: u64,
2323        audience_asserted: Option<String>,
2324        bytes: Option<u64>,
2325        sha256: Option<String>,
2326        outcome: &str,
2327        reason: Option<String>,
2328    ) {
2329        let event = PeerHandleRedeemEvent {
2330            event_type: "peer_handle_redeem",
2331            event_id: new_event_id(),
2332            parent_id: Some(self.session_event_id.clone()),
2333            session_id: self.session_id.clone(),
2334            timestamp: timestamp_ms(),
2335            handle_id: handle_id.to_string(),
2336            path,
2337            generation,
2338            audience_asserted,
2339            bytes,
2340            sha256,
2341            outcome: outcome.to_string(),
2342            reason,
2343        };
2344        self.append(&event).await;
2345    }
2346
2347    /// Records one fetch on the ingesting side, served or refused.
2348    #[allow(clippy::too_many_arguments)]
2349    pub(crate) async fn write_peer_file_fetch(
2350        &self,
2351        peer: &str,
2352        handle_id: &str,
2353        stored_path: Option<String>,
2354        bytes: Option<u64>,
2355        sha256: Option<String>,
2356        outcome: &str,
2357        reason: Option<String>,
2358    ) {
2359        let event = PeerFileFetchEvent {
2360            event_type: "peer_file_fetch",
2361            event_id: new_event_id(),
2362            parent_id: Some(self.session_event_id.clone()),
2363            session_id: self.session_id.clone(),
2364            timestamp: timestamp_ms(),
2365            peer: peer.to_string(),
2366            handle_id: handle_id.to_string(),
2367            stored_path,
2368            bytes,
2369            sha256,
2370            outcome: outcome.to_string(),
2371            reason,
2372        };
2373        self.append(&event).await;
2374    }
2375
2376    /// Records one launched child, while its delegation is still running.
2377    ///
2378    /// Every field is known by the time the child has reported its session id, so none is
2379    /// optional: a delegation with nothing to say here was never launched and writes no line.
2380    pub(crate) async fn write_delegation_start(
2381        &self,
2382        delegation_id: &str,
2383        capsule: &str,
2384        version: &str,
2385        child_session_id: &str,
2386        child_workdir: &str,
2387    ) {
2388        let event = DelegationStartEvent::new(
2389            &self.session_id,
2390            &self.session_event_id,
2391            delegation_id,
2392            capsule,
2393            version,
2394            child_session_id,
2395            child_workdir,
2396        );
2397        self.append(&event).await;
2398    }
2399
2400    /// Records one delegation ending. `delegation_id` and `child_session_id` are `None` when no
2401    /// child was launched — a delegation that never started names no id, and an audit record must
2402    /// not imply one exists.
2403    #[allow(clippy::too_many_arguments)]
2404    pub(crate) async fn write_delegation(
2405        &self,
2406        capsule: &str,
2407        version: &str,
2408        delegation_id: Option<String>,
2409        child_session_id: Option<String>,
2410        duration_ms: u64,
2411        outcome: &str,
2412        reason: Option<String>,
2413    ) {
2414        let event = DelegationEvent::new(
2415            &self.session_id,
2416            &self.session_event_id,
2417            capsule,
2418            version,
2419            delegation_id,
2420            child_session_id,
2421            duration_ms,
2422            outcome,
2423            reason,
2424        );
2425        self.append(&event).await;
2426    }
2427
2428    /// Records one change to the session's inference credential. Carries where the credential
2429    /// comes from and what happened to it, never the value.
2430    pub(crate) async fn write_inference_credential(
2431        &self,
2432        source: &'static str,
2433        credential: Option<&str>,
2434        change: CredentialChange,
2435    ) {
2436        let (change_name, trigger, status, retried, reason) = match change {
2437            CredentialChange::Rotated { trigger } => ("rotated", Some(trigger), None, None, None),
2438            CredentialChange::Rejected { status, retried } => {
2439                ("rejected", None, Some(status), Some(retried), None)
2440            }
2441            CredentialChange::Unreadable { reason } => {
2442                ("unreadable", None, None, None, Some(reason))
2443            }
2444        };
2445        let event = InferenceCredentialEvent {
2446            event_type: "inference_credential",
2447            event_id: new_event_id(),
2448            parent_id: Some(self.session_event_id.clone()),
2449            session_id: self.session_id.clone(),
2450            timestamp: timestamp_ms(),
2451            source,
2452            credential,
2453            change: change_name,
2454            trigger,
2455            status,
2456            retried,
2457            reason,
2458        };
2459        self.append(&event).await;
2460    }
2461
2462    async fn append(&self, event: &impl Serialize) {
2463        let Ok(mut line) = serde_json::to_string(event) else {
2464            return;
2465        };
2466        line.push('\n');
2467        let mut file = self.file.lock().await;
2468        if file.write_all(line.as_bytes()).await.is_ok() {
2469            let _ = file.flush().await;
2470        }
2471    }
2472}
2473
2474// -- Prior-session appender ---------------------------------------------------
2475
2476/// An `O_APPEND` handle to a *previous* session's `trace.jsonl`, opened by a resuming launch to
2477/// account for demoted commands that session left unreported.
2478///
2479/// The marker goes in the file that holds the unmatched `shell_detached` line rather than in the
2480/// resuming session's own trace, which keeps clearing O(1) instead of a scan of every sibling
2481/// session, and means [`crate::retention::prune_sessions`] removes a marker and the work it
2482/// accounts for together.
2483///
2484/// Writing to another session's trace is safe because that session's writer is dead — that is the
2485/// precondition for there being anything unaccounted — and because this obeys the same rule
2486/// [`ResourceTraceAppender`] does: one complete line per `write_all`, then flush, which `O_APPEND`
2487/// makes atomic against any other writer.
2488pub(crate) struct PriorSessionTraceAppender {
2489    file: File,
2490    /// The prior session's id, which is what its own lines carry.
2491    session_id: String,
2492    /// That session's `session_start` node, read back from the same file.
2493    session_event_id: Option<String>,
2494    /// Whether the file's last record is unterminated, which a writer killed mid-`write_all`
2495    /// leaves behind. The first appended line opens with a newline when it is, so the marker is
2496    /// a line of its own rather than spliced onto the torn one.
2497    terminate_torn_tail: bool,
2498}
2499
2500impl PriorSessionTraceAppender {
2501    /// `trace_path` is the prior session's `trace.jsonl`. Errors when it cannot be opened for
2502    /// appending, which the caller reports rather than reporting work it could not mark.
2503    pub(crate) async fn open(
2504        trace_path: &Path,
2505        session_id: String,
2506        session_event_id: Option<String>,
2507    ) -> std::io::Result<Self> {
2508        let terminate_torn_tail = !ends_with_newline(trace_path)?;
2509        let file = OpenOptions::new().append(true).open(trace_path).await?;
2510        Ok(Self {
2511            file,
2512            session_id,
2513            session_event_id,
2514            terminate_torn_tail,
2515        })
2516    }
2517
2518    /// Records one demoted command as lost, and reports whether the line landed.
2519    ///
2520    /// The result is not swallowed: a work id whose marker did not land must be dropped from the
2521    /// report rather than reported unmarked, because an unmarked work id is reported again by
2522    /// every later resume of the same session.
2523    pub(crate) async fn write_shell_lost(
2524        &mut self,
2525        work: &crate::detached::LostWork,
2526        reconciled_by_session: &str,
2527        reconciled_task_id: &str,
2528    ) -> std::io::Result<()> {
2529        let event = ShellLostEvent {
2530            event_type: "shell_lost",
2531            event_id: new_event_id(),
2532            parent_id: self.session_event_id.clone(),
2533            session_id: self.session_id.clone(),
2534            timestamp: timestamp_ms(),
2535            work_id: work.work_id.clone(),
2536            binary: work.binary.clone(),
2537            command: work.command.clone(),
2538            detached_at_ms: work.detached_at_ms,
2539            reconciled_by_session: reconciled_by_session.to_string(),
2540            reconciled_task_id: reconciled_task_id.to_string(),
2541        };
2542        let mut line = if self.terminate_torn_tail {
2543            "\n".to_string()
2544        } else {
2545            String::new()
2546        };
2547        line.push_str(&serde_json::to_string(&event).map_err(std::io::Error::other)?);
2548        line.push('\n');
2549        self.file.write_all(line.as_bytes()).await?;
2550        self.file.flush().await?;
2551        self.terminate_torn_tail = false;
2552        Ok(())
2553    }
2554}
2555
2556/// Whether the file's last byte is a newline. An empty file counts as terminated: there is no
2557/// record for an appended line to run into.
2558fn ends_with_newline(path: &Path) -> std::io::Result<bool> {
2559    use std::io::{Read, Seek, SeekFrom};
2560
2561    let mut file = std::fs::File::open(path)?;
2562    let length = file.metadata()?.len();
2563    if length == 0 {
2564        return Ok(true);
2565    }
2566    file.seek(SeekFrom::End(-1))?;
2567    let mut last = [0u8; 1];
2568    file.read_exact(&mut last)?;
2569    Ok(last[0] == b'\n')
2570}
2571
2572pub(crate) fn timestamp_ms() -> u64 {
2573    SystemTime::now()
2574        .duration_since(UNIX_EPOCH)
2575        .unwrap_or_default()
2576        .as_millis() as u64
2577}
2578
2579// ── Plan-scheduler events ────────────────────────────────────────────────────
2580
2581/// One step of a plan's DAG as authored, written into `plan_start`.
2582///
2583/// The shape is recorded once, up front, so the run stays legible for a step that never ran:
2584/// the trace names every step the plan declared whether or not the scheduler reached it.
2585#[derive(Serialize, Clone)]
2586pub(crate) struct PlanStepShape {
2587    pub(crate) step_id: String,
2588    /// `"tool"`, `"shell"` or `"capsule"`; `"unknown"` for a step whose kind cannot be inferred,
2589    /// which is a step the plan validator refuses.
2590    pub(crate) kind: &'static str,
2591    pub(crate) depends_on: Vec<String>,
2592    /// Whether the step carries an `if`, and so may settle without ever being dispatched.
2593    pub(crate) has_condition: bool,
2594}
2595
2596/// The plan's structure, written once before any step runs. Its `event_id` is the plan node
2597/// every other line of the run hangs off.
2598#[derive(Serialize)]
2599struct PlanStartEvent {
2600    event_type: &'static str,
2601    event_id: String,
2602    parent_id: Option<String>,
2603    session_id: String,
2604    timestamp: u64,
2605    plan_id: String,
2606    step_count: usize,
2607    steps: Vec<PlanStepShape>,
2608}
2609
2610/// One dispatched step, written as the scheduler hands it to a worker.
2611///
2612/// A step that settled without ever being dispatched — an `if` that evaluated false, a
2613/// dependency that never resolved — writes no line here, only a terminal `plan_step`. Joins to
2614/// that line on `(plan_id, step_id)`.
2615#[derive(Serialize)]
2616struct PlanStepStartEvent {
2617    event_type: &'static str,
2618    event_id: String,
2619    parent_id: Option<String>,
2620    session_id: String,
2621    timestamp: u64,
2622    plan_id: String,
2623    step_id: String,
2624    kind: &'static str,
2625    depends_on: Vec<String>,
2626}
2627
2628/// A settled step, written by the scheduler loop after the step's `on_error` policy has been
2629/// applied — which is why the worker thread does not write it: a step that failed under
2630/// `on_error: skip` settles as skipped in the returned report and must read as skipped here.
2631#[derive(Serialize)]
2632struct PlanStepEvent {
2633    event_type: &'static str,
2634    event_id: String,
2635    parent_id: Option<String>,
2636    session_id: String,
2637    timestamp: u64,
2638    plan_id: String,
2639    step_id: String,
2640    kind: &'static str,
2641    status: &'static str,
2642    attempts: u32,
2643    duration_ms: u64,
2644    #[serde(skip_serializing_if = "Option::is_none")]
2645    error: Option<String>,
2646    /// The interpolated input the step dispatched, with peer handle tokens redacted. Tool steps
2647    /// only — a shell step's command and a capsule step's task text are not carried here.
2648    #[serde(skip_serializing_if = "Option::is_none")]
2649    input: Option<Value>,
2650    /// The tool's self-declared `state_effect` for this step, lifted verbatim from
2651    /// `tool-result.metadata`. Absent when the tool declared nothing. Feeds the same
2652    /// redundant-call analysis `tool_call.state_effect` does.
2653    #[serde(skip_serializing_if = "Option::is_none")]
2654    state_effect: Option<String>,
2655    /// The resource this step addressed, as the tool declared it. Opaque and never parsed here.
2656    #[serde(skip_serializing_if = "Option::is_none")]
2657    resource_id: Option<String>,
2658}
2659
2660/// The run's summary, written once as `plan::execute` returns, whatever ended it.
2661#[derive(Serialize)]
2662struct PlanEndEvent {
2663    event_type: &'static str,
2664    event_id: String,
2665    parent_id: Option<String>,
2666    session_id: String,
2667    timestamp: u64,
2668    plan_id: String,
2669    outcome: &'static str,
2670    #[serde(skip_serializing_if = "Option::is_none")]
2671    failed_step: Option<String>,
2672    steps_total: usize,
2673    steps_succeeded: usize,
2674    steps_failed: usize,
2675    steps_skipped: usize,
2676    duration_ms: u64,
2677    /// Why the run ended when the reason was not a step's own failure: a plan that would not
2678    /// parse, a cgroup scope the host refused, a DAG with nothing left to run.
2679    #[serde(skip_serializing_if = "Option::is_none")]
2680    reason: Option<String>,
2681}
2682
2683/// Everything the scheduler loop knows about a settled step, as one argument.
2684pub(crate) struct PlanStepRecord {
2685    pub(crate) plan_id: String,
2686    pub(crate) step_id: String,
2687    pub(crate) kind: &'static str,
2688    /// `"success"`, `"failed"` or `"skipped"` — the status the returned `ExecutionReport` carries
2689    /// for this step, so the two can never disagree.
2690    pub(crate) status: &'static str,
2691    /// How many times the step was dispatched. `0` for a step that settled without dispatch.
2692    pub(crate) attempts: u32,
2693    pub(crate) duration_ms: u64,
2694    pub(crate) error: Option<String>,
2695    pub(crate) input: Option<Value>,
2696    pub(crate) state_effect: Option<String>,
2697    pub(crate) resource_id: Option<String>,
2698}
2699
2700/// Everything the scheduler knows about a finished run, as one argument.
2701pub(crate) struct PlanEndRecord {
2702    pub(crate) plan_id: String,
2703    pub(crate) outcome: &'static str,
2704    pub(crate) failed_step: Option<String>,
2705    pub(crate) steps_total: usize,
2706    pub(crate) steps_succeeded: usize,
2707    pub(crate) steps_failed: usize,
2708    pub(crate) steps_skipped: usize,
2709    pub(crate) duration_ms: u64,
2710    pub(crate) reason: Option<String>,
2711}
2712
2713/// A third `O_APPEND` handle to the session's `trace.jsonl`, held by a plan run.
2714///
2715/// Synchronous where [`ResourceTraceAppender`] is async, because [`crate::plan::execute`] is: it
2716/// runs its ready set on plain threads inside a `thread::scope`, so there is no runtime to await
2717/// on and no `await` for a lock to be held across. Interleaving with [`TraceWriter`] and with
2718/// the resource plane is safe on the same terms they interleave with each other — one complete
2719/// line per `write_all`, then flush, which `O_APPEND` makes atomic against the other writers.
2720///
2721/// Handing one of these to a [`crate::plan::SchedulerContext`] is the whole of the opt-in: a
2722/// context carrying `None` opens no file and writes no line.
2723pub struct PlanTraceAppender {
2724    file: std::sync::Mutex<std::fs::File>,
2725    session_id: String,
2726    /// The session node a run's `plan_start` hangs off — [`TraceWriter`]'s own
2727    /// `session_start`, handed over at open time so an appended line names a real parent.
2728    session_event_id: String,
2729}
2730
2731impl PlanTraceAppender {
2732    pub fn open(
2733        workdir: &Path,
2734        session_id: String,
2735        session_event_id: String,
2736    ) -> std::io::Result<Self> {
2737        let file = std::fs::OpenOptions::new()
2738            .create(true)
2739            .append(true)
2740            .open(workdir.join("trace.jsonl"))?;
2741        Ok(Self {
2742            file: std::sync::Mutex::new(file),
2743            session_id,
2744            session_event_id,
2745        })
2746    }
2747
2748    /// Records the plan's DAG and returns the plan node every later line of this run hangs off.
2749    pub(crate) fn write_plan_start(&self, plan_id: &str, steps: Vec<PlanStepShape>) -> String {
2750        let event_id = new_event_id();
2751        let event = PlanStartEvent {
2752            event_type: "plan_start",
2753            event_id: event_id.clone(),
2754            parent_id: Some(self.session_event_id.clone()),
2755            session_id: self.session_id.clone(),
2756            timestamp: timestamp_ms(),
2757            plan_id: plan_id.to_string(),
2758            step_count: steps.len(),
2759            steps,
2760        };
2761        self.append(&event);
2762        event_id
2763    }
2764
2765    pub(crate) fn write_plan_step_start(
2766        &self,
2767        plan_node: &str,
2768        plan_id: &str,
2769        step_id: &str,
2770        kind: &'static str,
2771        depends_on: Vec<String>,
2772    ) {
2773        let event = PlanStepStartEvent {
2774            event_type: "plan_step_start",
2775            event_id: new_event_id(),
2776            parent_id: Some(plan_node.to_string()),
2777            session_id: self.session_id.clone(),
2778            timestamp: timestamp_ms(),
2779            plan_id: plan_id.to_string(),
2780            step_id: step_id.to_string(),
2781            kind,
2782            depends_on,
2783        };
2784        self.append(&event);
2785    }
2786
2787    pub(crate) fn write_plan_step(&self, plan_node: &str, record: PlanStepRecord) {
2788        // On the same terms `TraceWriter::write_tool_call` redacts a tool call's input: a peer
2789        // handle is a credential and this file is durable.
2790        let input = record.input.map(|mut value| {
2791            crate::peer_handoff::redact_handles_in_json(&mut value);
2792            value
2793        });
2794        let event = PlanStepEvent {
2795            event_type: "plan_step",
2796            event_id: new_event_id(),
2797            parent_id: Some(plan_node.to_string()),
2798            session_id: self.session_id.clone(),
2799            timestamp: timestamp_ms(),
2800            plan_id: record.plan_id,
2801            step_id: record.step_id,
2802            kind: record.kind,
2803            status: record.status,
2804            attempts: record.attempts,
2805            duration_ms: record.duration_ms,
2806            error: record.error,
2807            input,
2808            state_effect: record.state_effect,
2809            resource_id: record.resource_id,
2810        };
2811        self.append(&event);
2812    }
2813
2814    /// Records the run's summary. `plan_node` is `None` for a run that never had one — a plan
2815    /// file that would not parse describes no DAG — and the line hangs off the session instead.
2816    pub(crate) fn write_plan_end(&self, plan_node: Option<&str>, record: PlanEndRecord) {
2817        let event = PlanEndEvent {
2818            event_type: "plan_end",
2819            event_id: new_event_id(),
2820            parent_id: Some(
2821                plan_node
2822                    .map(str::to_string)
2823                    .unwrap_or_else(|| self.session_event_id.clone()),
2824            ),
2825            session_id: self.session_id.clone(),
2826            timestamp: timestamp_ms(),
2827            plan_id: record.plan_id,
2828            outcome: record.outcome,
2829            failed_step: record.failed_step,
2830            steps_total: record.steps_total,
2831            steps_succeeded: record.steps_succeeded,
2832            steps_failed: record.steps_failed,
2833            steps_skipped: record.steps_skipped,
2834            duration_ms: record.duration_ms,
2835            reason: record.reason,
2836        };
2837        self.append(&event);
2838    }
2839
2840    /// Records one child a `capsule` step launched, while its delegation is still running.
2841    ///
2842    /// The synchronous twin of [`ResourceTraceAppender::write_delegation_start`]. Both go through
2843    /// [`DelegationStartEvent::new`], so the two lines are identical but for their ids and
2844    /// clocks — held by there being one constructor rather than by a test.
2845    pub(crate) fn write_delegation_start(
2846        &self,
2847        delegation_id: &str,
2848        capsule: &str,
2849        version: &str,
2850        child_session_id: &str,
2851        child_workdir: &str,
2852    ) {
2853        let event = DelegationStartEvent::new(
2854            &self.session_id,
2855            &self.session_event_id,
2856            delegation_id,
2857            capsule,
2858            version,
2859            child_session_id,
2860            child_workdir,
2861        );
2862        self.append(&event);
2863    }
2864
2865    /// Records one `capsule` step's delegation ending. `delegation_id` and `child_session_id` are
2866    /// `None` when no child was launched, on the same terms
2867    /// [`ResourceTraceAppender::write_delegation`] passes them.
2868    ///
2869    /// One pair per *attempt*: a step with retries delegates once per attempt, so it writes one
2870    /// of these per attempt. Two `failed` lines and a `completed` one is what happened, not a
2871    /// duplicate of one thing.
2872    #[allow(clippy::too_many_arguments)]
2873    pub(crate) fn write_delegation(
2874        &self,
2875        capsule: &str,
2876        version: &str,
2877        delegation_id: Option<String>,
2878        child_session_id: Option<String>,
2879        duration_ms: u64,
2880        outcome: &str,
2881        reason: Option<String>,
2882    ) {
2883        let event = DelegationEvent::new(
2884            &self.session_id,
2885            &self.session_event_id,
2886            capsule,
2887            version,
2888            delegation_id,
2889            child_session_id,
2890            duration_ms,
2891            outcome,
2892            reason,
2893        );
2894        self.append(&event);
2895    }
2896
2897    /// Writes one complete line and flushes it. Every failure is swallowed, exactly as
2898    /// [`ResourceTraceAppender::append`] swallows one: a trace that cannot be written must never
2899    /// turn a step that ran into a step that failed.
2900    fn append(&self, event: &impl Serialize) {
2901        use std::io::Write as _;
2902
2903        let Ok(mut line) = serde_json::to_string(event) else {
2904            return;
2905        };
2906        line.push('\n');
2907        let Ok(mut file) = self.file.lock() else {
2908            return;
2909        };
2910        if file.write_all(line.as_bytes()).is_ok() {
2911            let _ = file.flush();
2912        }
2913    }
2914}
2915
2916// ── Unit tests ────────────────────────────────────────────────────────────────
2917
2918#[cfg(test)]
2919mod tests {
2920    use super::*;
2921    use crate::{
2922        containment::scope_report_for_tier, sandbox::EnforcementTier, sealed::UsernsGrant,
2923        trace_blobs::BLOB_DIR_NAME, types::CapabilityPolicy,
2924    };
2925    use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant};
2926    use serde_json::Value;
2927
2928    use crate::origin::TaskOrigin;
2929
2930    /// The class an A2A task carrying no origin claim resolves to.
2931    fn event_provenance() -> TaskProvenance {
2932        TaskProvenance::derive(TaskOrigin::Event, None)
2933    }
2934
2935    /// A [`ScopeReport`] built the way production builds one — through
2936    /// `containment::scope_report_for_tier` with an *explicit* tier, never a live probe, so these
2937    /// tests assert the same values on a Landlock-capable host and a laptop that has none.
2938    fn report_for(
2939        declared: ContainmentClass,
2940        tier: EnforcementTier,
2941        workdir_exec: bool,
2942    ) -> ScopeReport {
2943        scope_report_for_tier(
2944            &CapabilityPolicy {
2945                workdir_exec_allowed: workdir_exec,
2946                ..CapabilityPolicy::default()
2947            },
2948            declared,
2949            tier,
2950            None,
2951            None,
2952            None,
2953            Vec::new(),
2954            Vec::new(),
2955            Vec::new(),
2956            crate::cgroup::IoMaxReport::default(),
2957        )
2958    }
2959
2960    fn demotion_info() -> crate::detached::DetachedDispatchInfo {
2961        crate::detached::DetachedDispatchInfo {
2962            work_id: "wrk_0a1b2c3d4e5f6a7b".to_string(),
2963            binary: "/usr/bin/bash".to_string(),
2964            command: "make -j8".to_string(),
2965            grace_ms: 1_000,
2966        }
2967    }
2968
2969    /// A demotion whose marker cannot be written stands anyway: the write failure is reported and
2970    /// swallowed, and the session carries on with the command running in the background.
2971    #[tokio::test]
2972    async fn a_demotion_marker_that_cannot_be_written_does_not_fail_the_demotion() {
2973        let dir = tempfile::tempdir().unwrap();
2974        // `/dev/full` opens and fails every write with `ENOSPC`, which is the one way to get a
2975        // trace handle that is valid and unwritable without root.
2976        std::os::unix::fs::symlink("/dev/full", dir.path().join("trace.jsonl")).unwrap();
2977        let mut writer = make_writer(dir.path()).await;
2978        let info = demotion_info();
2979
2980        assert!(
2981            writer
2982                .write_shell_detached(1, &info.work_id, &info.binary, &info.command, info.grace_ms)
2983                .await
2984                .is_err(),
2985            "the sink under this writer must be unwritable for the claim below to mean anything"
2986        );
2987        crate::agent::record_demotion(&mut writer, 1, &info).await;
2988    }
2989
2990    /// The ordinary path writes one `shell_detached` and nothing else.
2991    #[tokio::test]
2992    async fn a_recorded_demotion_writes_only_its_own_marker() {
2993        let dir = tempfile::tempdir().unwrap();
2994        let mut writer = make_writer(dir.path()).await;
2995        crate::agent::record_demotion(&mut writer, 3, &demotion_info()).await;
2996
2997        let events = read_events(dir.path());
2998        assert_eq!(events.len(), 1);
2999        assert_eq!(events[0]["event_type"], "shell_detached");
3000        assert_eq!(events[0]["work_id"], "wrk_0a1b2c3d4e5f6a7b");
3001        assert_eq!(events[0]["turn"], 3);
3002    }
3003
3004    #[tokio::test]
3005    async fn an_unrecorded_demotion_names_the_work_id_and_the_write_error() {
3006        let dir = tempfile::tempdir().unwrap();
3007        let mut writer = make_writer(dir.path()).await;
3008        writer
3009            .write_shell_detach_unrecorded(2, "wrk_0a1b", "/usr/bin/bash", "No space left")
3010            .await
3011            .unwrap();
3012
3013        let events = read_events(dir.path());
3014        assert_eq!(events.len(), 1);
3015        assert_eq!(events[0]["event_type"], "shell_detach_unrecorded");
3016        assert_eq!(events[0]["turn"], 2);
3017        assert_eq!(events[0]["work_id"], "wrk_0a1b");
3018        assert_eq!(events[0]["binary"], "/usr/bin/bash");
3019        assert_eq!(events[0]["reason"], "No space left");
3020    }
3021
3022    /// A command still running when the session ended has no exit code, no output file and no
3023    /// byte count, and its line carries none of the three — omitted rather than `null`, so the
3024    /// shape is identical to a line written before those fields existed.
3025    #[tokio::test]
3026    async fn an_abandoned_command_still_running_omits_the_fields_it_cannot_know() {
3027        let dir = tempfile::tempdir().unwrap();
3028        let mut writer = make_writer(dir.path()).await;
3029        writer
3030            .write_shell_abandoned(
3031                "wrk_0a1b2c3d4e5f6a7b",
3032                "/usr/bin/bash",
3033                "sleep 45; echo done",
3034                45_012,
3035                None,
3036            )
3037            .await
3038            .unwrap();
3039
3040        let events = read_events(dir.path());
3041        assert_eq!(events.len(), 1);
3042        let event = &events[0];
3043        assert_eq!(event["event_type"], "shell_abandoned");
3044        assert_eq!(event["work_id"], "wrk_0a1b2c3d4e5f6a7b");
3045        assert_eq!(event["binary"], "/usr/bin/bash");
3046        assert_eq!(event["command"], "sleep 45; echo done");
3047        assert_eq!(event["running_ms"], 45_012);
3048        for absent in ["exit_code", "output_path", "output_bytes"] {
3049            assert!(
3050                event.get(absent).is_none(),
3051                "{absent} must be omitted, not written as null: {event}"
3052            );
3053        }
3054    }
3055
3056    /// A command that finished during teardown carries the exit code, the output path and the
3057    /// byte count that do exist for it, so a trace reader can tell it from one still running
3058    /// without reading anything else.
3059    #[tokio::test]
3060    async fn an_abandoned_command_that_finished_carries_its_result() {
3061        let dir = tempfile::tempdir().unwrap();
3062        let mut writer = make_writer(dir.path()).await;
3063        writer
3064            .write_shell_abandoned(
3065                "wrk_0a1b2c3d4e5f6a7b",
3066                "/usr/bin/bash",
3067                "sleep 2; echo done",
3068                2_014,
3069                Some((0, "logs/wrk_0a1b2c3d4e5f6a7b.log", 91)),
3070            )
3071            .await
3072            .unwrap();
3073
3074        let event = &read_events(dir.path())[0];
3075        assert_eq!(event["event_type"], "shell_abandoned");
3076        assert_eq!(event["running_ms"], 2_014);
3077        assert_eq!(event["exit_code"], 0);
3078        assert_eq!(event["output_path"], "logs/wrk_0a1b2c3d4e5f6a7b.log");
3079        assert_eq!(event["output_bytes"], 91);
3080    }
3081
3082    async fn make_writer(dir: &std::path::Path) -> TraceWriter {
3083        make_writer_with_opts(dir, TraceCapture::Meta).await
3084    }
3085
3086    async fn make_writer_with_opts(dir: &std::path::Path, capture: TraceCapture) -> TraceWriter {
3087        make_writer_with_prompt(dir, capture, None, false).await
3088    }
3089
3090    async fn make_writer_with_prompt(
3091        dir: &std::path::Path,
3092        capture: TraceCapture,
3093        system_prompt: Option<&str>,
3094        system_prompt_overridden: bool,
3095    ) -> TraceWriter {
3096        TraceWriter::open(
3097            dir,
3098            "test-session-id".to_string(),
3099            "test-capsule".to_string(),
3100            "1.0.0".to_string(),
3101            "claude-test".to_string(),
3102            vec!["shell".to_string()],
3103            report_for(
3104                ContainmentClass::Advisory,
3105                EnforcementTier::EnvironmentOnly,
3106                false,
3107            ),
3108            capture,
3109            system_prompt.map(str::to_string),
3110            system_prompt_overridden,
3111            None,
3112            None,
3113            None,
3114            None,
3115        )
3116        .await
3117        .unwrap()
3118    }
3119
3120    fn read_events(dir: &std::path::Path) -> Vec<Value> {
3121        let content = std::fs::read_to_string(dir.join("trace.jsonl")).unwrap();
3122        content
3123            .lines()
3124            .filter(|l| !l.is_empty())
3125            .map(|l| serde_json::from_str(l).unwrap())
3126            .collect()
3127    }
3128
3129    #[tokio::test]
3130    async fn session_start_fields() {
3131        let dir = tempfile::tempdir().unwrap();
3132        let mut w = make_writer(dir.path()).await;
3133        w.write_session_start(10, vec!["bash".to_string()])
3134            .await
3135            .unwrap();
3136        w.flush().await.unwrap();
3137
3138        let events = read_events(dir.path());
3139        assert_eq!(events.len(), 1);
3140        let e = &events[0];
3141        assert_eq!(e["event_type"], "session_start");
3142        assert_eq!(e["session_id"], "test-session-id");
3143        assert_eq!(e["capsule_name"], "test-capsule");
3144        assert_eq!(e["capsule_version"], "1.0.0");
3145        assert_eq!(e["model"], "claude-test");
3146        assert_eq!(e["max_turns"], 10);
3147        assert_eq!(e["capabilities"], serde_json::json!(["shell"]));
3148        assert_eq!(e["tools_declared"], serde_json::json!(["bash"]));
3149        assert_eq!(e["containment_declared"], "advisory");
3150        assert_eq!(e["containment_achieved"], "advisory");
3151        assert_eq!(e["workdir_exec"], false);
3152        assert!(e["timestamp"].as_u64().unwrap() > 0);
3153    }
3154
3155    /// The three `system_prompt_source` values, each paired with the hash the same call derives.
3156    /// A manifest prompt and a CLI override carrying the *same* text differ only in the source
3157    /// field — which is the whole reason the override flag is threaded through separately rather
3158    /// than inferred from the resolved prompt.
3159    #[tokio::test]
3160    async fn session_start_records_system_prompt_source_and_hash() {
3161        let expected_sha = murmur_artifact::sha256_hex(b"Be terse.");
3162
3163        for (prompt, overridden, source, sha) in [
3164            (Some("Be terse."), false, "manifest", Some(&expected_sha)),
3165            (Some("Be terse."), true, "cli", Some(&expected_sha)),
3166            (None, false, "none", None),
3167            // `--system-prompt ""` cleared the manifest's prompt: no prompt is in effect, but the
3168            // operator made that call, so the source is the CLI and not `"none"`.
3169            (None, true, "cli", None),
3170        ] {
3171            let dir = tempfile::tempdir().unwrap();
3172            let mut w =
3173                make_writer_with_prompt(dir.path(), TraceCapture::Meta, prompt, overridden).await;
3174            w.write_session_start(1, Vec::new()).await.unwrap();
3175            w.flush().await.unwrap();
3176
3177            let e = &read_events(dir.path())[0];
3178            assert_eq!(e["system_prompt_source"], source, "prompt={prompt:?}");
3179            match sha {
3180                Some(sha) => assert_eq!(e["system_prompt_sha256"], sha.as_str()),
3181                None => assert!(
3182                    e["system_prompt_sha256"].is_null(),
3183                    "no prompt in effect must hash to null, got {}",
3184                    e["system_prompt_sha256"]
3185                ),
3186            }
3187        }
3188    }
3189
3190    /// `session_start` never carries the prompt text. The hash is written in every capture
3191    /// mode; under `content` the bytes behind it are a blob named by that same hash, which is
3192    /// how a reader gets from the record to the prompt.
3193    #[tokio::test]
3194    async fn session_start_records_the_prompt_by_hash_and_stores_it_only_under_content() {
3195        let sha = murmur_artifact::sha256_hex(b"Be terse.");
3196
3197        let dir = tempfile::tempdir().unwrap();
3198        let mut w =
3199            make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), false).await;
3200        w.write_session_start(1, Vec::new()).await.unwrap();
3201        w.flush().await.unwrap();
3202
3203        let e = &read_events(dir.path())[0];
3204        assert!(
3205            e.get("system_prompt").is_none(),
3206            "the verbatim prompt is not a session_start field, got {e}"
3207        );
3208        assert_eq!(e["system_prompt_sha256"], sha.as_str());
3209        assert!(
3210            !dir.path().join(BLOB_DIR_NAME).exists(),
3211            "meta stores no bodies"
3212        );
3213
3214        let opted_in = tempfile::tempdir().unwrap();
3215        let mut w = make_writer_with_prompt(
3216            opted_in.path(),
3217            TraceCapture::Content,
3218            Some("Be terse."),
3219            false,
3220        )
3221        .await;
3222        w.write_session_start(1, Vec::new()).await.unwrap();
3223        w.flush().await.unwrap();
3224
3225        let event = &read_events(opted_in.path())[0];
3226        assert!(event.get("system_prompt").is_none());
3227        assert_eq!(event["system_prompt_sha256"], sha.as_str());
3228        let blob = opted_in.path().join(BLOB_DIR_NAME).join(&sha);
3229        assert_eq!(std::fs::read(&blob).unwrap(), b"Be terse.");
3230    }
3231
3232    /// Opting in cannot conjure a prompt that was never in effect — and a session with no
3233    /// prompt and nothing else to store leaves no blob directory at all.
3234    #[tokio::test]
3235    async fn content_capture_stores_no_prompt_blob_when_there_is_no_prompt() {
3236        let dir = tempfile::tempdir().unwrap();
3237        let mut w = make_writer_with_prompt(dir.path(), TraceCapture::Content, None, false).await;
3238        w.write_session_start(1, Vec::new()).await.unwrap();
3239        w.flush().await.unwrap();
3240
3241        let e = &read_events(dir.path())[0];
3242        assert!(e.get("system_prompt").is_none());
3243        assert!(e["system_prompt_sha256"].is_null());
3244        assert!(!dir.path().join(BLOB_DIR_NAME).exists());
3245    }
3246
3247    /// The prompt is a session constant, like `model` — every task's `session_start` repeats it.
3248    #[tokio::test]
3249    async fn every_task_session_start_repeats_the_same_system_prompt_record() {
3250        let dir = tempfile::tempdir().unwrap();
3251        let mut w =
3252            make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), true).await;
3253        w.write_session_start(1, Vec::new()).await.unwrap();
3254        w.write_session_start(1, Vec::new()).await.unwrap();
3255        w.flush().await.unwrap();
3256
3257        let events = read_events(dir.path());
3258        assert_eq!(events.len(), 2);
3259        assert_eq!(
3260            events[0]["system_prompt_source"],
3261            events[1]["system_prompt_source"]
3262        );
3263        assert_eq!(
3264            events[0]["system_prompt_sha256"],
3265            events[1]["system_prompt_sha256"]
3266        );
3267        assert_eq!(events[1]["system_prompt_source"], "cli");
3268    }
3269
3270    /// The pairing an operator reads a trace for: `workdir_exec: true` next to the `advisory` it
3271    /// forces, on a session whose *declared* floor was higher. Without the flag on the record, a
3272    /// trace showing `achieved: advisory` is indistinguishable from one written on a host with no
3273    /// Landlock at all.
3274    #[tokio::test]
3275    async fn session_start_records_workdir_exec_next_to_the_class_it_forced() {
3276        let dir = tempfile::tempdir().unwrap();
3277        let mut w = TraceWriter::open(
3278            dir.path(),
3279            "test-session-id".to_string(),
3280            "test-capsule".to_string(),
3281            "1.0.0".to_string(),
3282            "claude-test".to_string(),
3283            Vec::new(),
3284            // A host that could back `scoped`, capped to `advisory` by the declaration alone.
3285            report_for(
3286                ContainmentClass::Advisory,
3287                EnforcementTier::KernelFull,
3288                true,
3289            ),
3290            TraceCapture::Meta,
3291            None,
3292            false,
3293            None,
3294            None,
3295            None,
3296            None,
3297        )
3298        .await
3299        .unwrap();
3300        w.write_session_start(1, Vec::new()).await.unwrap();
3301        w.flush().await.unwrap();
3302
3303        let events = read_events(dir.path());
3304        assert_eq!(events[0]["workdir_exec"], true);
3305        assert_eq!(events[0]["containment_achieved"], "advisory");
3306    }
3307
3308    /// A declared floor and an achieved class are recorded independently — the trace shows
3309    /// what was asked for next to what the host actually gave, not one derived from the other.
3310    #[tokio::test]
3311    async fn session_start_records_declared_and_achieved_containment_separately() {
3312        let dir = tempfile::tempdir().unwrap();
3313        let mut w = TraceWriter::open(
3314            dir.path(),
3315            "test-session-id".to_string(),
3316            "test-capsule".to_string(),
3317            "1.0.0".to_string(),
3318            "claude-test".to_string(),
3319            Vec::new(),
3320            report_for(ContainmentClass::Sealed, EnforcementTier::KernelFull, false),
3321            TraceCapture::Meta,
3322            None,
3323            false,
3324            None,
3325            None,
3326            None,
3327            None,
3328        )
3329        .await
3330        .unwrap();
3331        w.write_session_start(1, Vec::new()).await.unwrap();
3332        w.flush().await.unwrap();
3333
3334        let events = read_events(dir.path());
3335        assert_eq!(events[0]["containment_declared"], "sealed");
3336        assert_eq!(events[0]["containment_achieved"], "scoped");
3337    }
3338
3339    /// The audit property the containment feature exists to provide: two sessions that reached
3340    /// the *same* achieved class through *different* host permissions must not produce the same
3341    /// record. A `sealed` result obtained through the shipped AppArmor profile and one obtained on
3342    /// a host whose unprivileged-userns hardening is switched off for every binary differ in
3343    /// nothing else the event carries, so `userns_grant` is what keeps the two apart.
3344    ///
3345    /// The tier is a literal on both sides, so the only thing that differs is the grant.
3346    #[tokio::test]
3347    async fn session_start_distinguishes_two_hosts_with_the_same_achieved_class() {
3348        async fn event_for(grant: UsernsGrant) -> Value {
3349            let report = scope_report_for_tier(
3350                &CapabilityPolicy::default(),
3351                ContainmentClass::Sealed,
3352                EnforcementTier::KernelSealed,
3353                None,
3354                Some(grant),
3355                None,
3356                Vec::new(),
3357                Vec::new(),
3358                Vec::new(),
3359                crate::cgroup::IoMaxReport::default(),
3360            );
3361            let dir = tempfile::tempdir().unwrap();
3362            let mut w = TraceWriter::open(
3363                dir.path(),
3364                "test-session-id".to_string(),
3365                "test-capsule".to_string(),
3366                "1.0.0".to_string(),
3367                "claude-test".to_string(),
3368                Vec::new(),
3369                report,
3370                TraceCapture::Meta,
3371                None,
3372                false,
3373                None,
3374                None,
3375                None,
3376                None,
3377            )
3378            .await
3379            .unwrap();
3380            w.write_session_start(1, Vec::new()).await.unwrap();
3381            w.flush().await.unwrap();
3382            let mut event = read_events(dir.path()).remove(0);
3383            // The one field that legitimately differs between two runs, removed so the assertion
3384            // below is about the grant and nothing else.
3385            event.as_object_mut().unwrap().remove("timestamp");
3386            event
3387        }
3388
3389        let through_profile = event_for(UsernsGrant::ProfileConfining).await;
3390        let host_wide = event_for(UsernsGrant::RestrictionDisabledHostWide).await;
3391
3392        assert_eq!(through_profile["containment_achieved"], "sealed");
3393        assert_eq!(host_wide["containment_achieved"], "sealed");
3394        assert_eq!(through_profile["userns_grant"], "profile_confining");
3395        assert_eq!(host_wide["userns_grant"], "restriction_disabled_host_wide");
3396        assert_ne!(
3397            through_profile, host_wide,
3398            "two hosts granting the user namespace by different mechanisms must not write the \
3399             same session_start record"
3400        );
3401
3402        // Written for every Linux host, never skipped — its absence identifies a runtime that
3403        // predates the key rather than a host that was not asked.
3404        let unprobed = scope_report_for_tier(
3405            &CapabilityPolicy::default(),
3406            ContainmentClass::Advisory,
3407            EnforcementTier::EnvironmentOnly,
3408            None,
3409            None,
3410            None,
3411            Vec::new(),
3412            Vec::new(),
3413            Vec::new(),
3414            crate::cgroup::IoMaxReport::default(),
3415        );
3416        assert!(
3417            serde_json::to_value(&unprobed).unwrap()["userns_grant"].is_null(),
3418            "off Linux the key is present and null, not absent"
3419        );
3420    }
3421
3422    /// Central property: `session_start.effective_grants` is the *whole* `ScopeReport`,
3423    /// serialized byte-for-byte as `mur run --explain-scope --json` prints it — not a re-derived
3424    /// summary of it. Asserted by comparing against `serde_json::to_value` of the very report
3425    /// handed to the writer, so any field added to `ScopeReport` later is covered without this
3426    /// test being touched.
3427    ///
3428    /// The tier is a literal, never a probe: this asserts the recording, not the host.
3429    #[tokio::test]
3430    async fn session_start_records_the_whole_scope_report_as_effective_grants() {
3431        let policy = CapabilityPolicy {
3432            network_allow: vec!["https://api.example.com".to_string()],
3433            unix_sockets_allowed: false,
3434            filesystem_scope: Some("workdir".to_string()),
3435            shell_allow: vec!["python3".to_string()],
3436            spawn_allow: vec!["helper".to_string()],
3437            env_allow: vec!["TZ".to_string()],
3438            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
3439                binary: "python3".to_string(),
3440                dirs: vec![InterpreterRuntimeDir {
3441                    path: "/usr/lib/python3.11".to_string(),
3442                    list_dir: true,
3443                }],
3444            }],
3445            ..CapabilityPolicy::default()
3446        };
3447        let expected = scope_report_for_tier(
3448            &policy,
3449            ContainmentClass::Scoped,
3450            EnforcementTier::KernelFull,
3451            None,
3452            None,
3453            None,
3454            // Populated rather than empty so the whole-report assertion below really covers this
3455            // field: an empty vec would pass whether or not it was written at all.
3456            vec![crate::containment::StateStoreReport {
3457                artifact: "notes-tool".to_string(),
3458                store: "shey".to_string(),
3459                host_path: "/home/dev/.murmur/state/shey".to_string(),
3460            }],
3461            // Populated for the same reason as `state_stores` above.
3462            vec!["config-echo".to_string()],
3463            vec![crate::containment::PreopenReport {
3464                artifact: "notes-tool".to_string(),
3465                role: "tool".to_string(),
3466                scope: None,
3467                surface: crate::containment::PreopenSurface::WholeWorkdir,
3468            }],
3469            crate::cgroup::IoMaxReport::default(),
3470        );
3471
3472        let dir = tempfile::tempdir().unwrap();
3473        let mut w = TraceWriter::open(
3474            dir.path(),
3475            "test-session-id".to_string(),
3476            "test-capsule".to_string(),
3477            "1.0.0".to_string(),
3478            "claude-test".to_string(),
3479            vec!["network".to_string(), "shell".to_string()],
3480            expected.clone(),
3481            TraceCapture::Meta,
3482            None,
3483            false,
3484            None,
3485            None,
3486            None,
3487            None,
3488        )
3489        .await
3490        .unwrap();
3491        w.write_session_start(1, Vec::new()).await.unwrap();
3492        w.flush().await.unwrap();
3493
3494        let events = read_events(dir.path());
3495        assert_eq!(
3496            events[0]["effective_grants"],
3497            serde_json::to_value(&expected).unwrap()
3498        );
3499
3500        // The category-name summary that predates this field is unchanged, and the three
3501        // top-level containment fields agree with the report they are now read from.
3502        assert_eq!(
3503            events[0]["capabilities"],
3504            serde_json::json!(["network", "shell"])
3505        );
3506        assert_eq!(
3507            events[0]["effective_grants"]["state_stores"],
3508            serde_json::json!([{
3509                "artifact": "notes-tool",
3510                "store": "shey",
3511                "host_path": "/home/dev/.murmur/state/shey",
3512            }])
3513        );
3514        // Names only: what an artifact is configured *with* is operator plaintext and reaches
3515        // no trace record.
3516        assert_eq!(
3517            events[0]["effective_grants"]["configured_artifacts"],
3518            serde_json::json!(["config-echo"])
3519        );
3520        // The per-artifact preopen set reaches the trace with no trace-side plumbing: the report
3521        // is cloned whole, so `filesystem_scope` above (which narrows no guest) and the surface a
3522        // guest is actually opened into are both on the record.
3523        assert_eq!(
3524            events[0]["effective_grants"]["preopens"],
3525            serde_json::json!([{
3526                "artifact": "notes-tool",
3527                "role": "tool",
3528                "scope": null,
3529                "surface": "whole-workdir",
3530            }])
3531        );
3532        assert_eq!(events[0]["containment_declared"], "scoped");
3533        assert_eq!(events[0]["containment_achieved"], "scoped");
3534        assert_eq!(events[0]["workdir_exec"], false);
3535        assert_eq!(
3536            events[0]["effective_grants"]["network_allow"],
3537            serde_json::json!(["https://api.example.com"])
3538        );
3539        assert_eq!(
3540            events[0]["effective_grants"]["interpreter_runtime_grants"],
3541            serde_json::json!(["python3: /usr/lib/python3.11 (list_dir)"])
3542        );
3543    }
3544
3545    #[tokio::test]
3546    async fn inference_fields_and_snake_case() {
3547        let dir = tempfile::tempdir().unwrap();
3548        let mut w = make_writer(dir.path()).await;
3549        w.write_inference(
3550            0,
3551            100,
3552            50,
3553            "tool_call".to_string(),
3554            Some("tool_call"),
3555            Some("bash".to_string()),
3556            None,
3557            None,
3558            vec!["msg_0000000000000000000000000000000a".to_string()],
3559            None,
3560        )
3561        .await
3562        .unwrap();
3563        w.flush().await.unwrap();
3564
3565        let events = read_events(dir.path());
3566        let e = &events[0];
3567        assert_eq!(e["event_type"], "inference");
3568        assert_eq!(
3569            e["message_ids"],
3570            serde_json::json!(["msg_0000000000000000000000000000000a"]),
3571            "an agent-loop turn names the messages its request embedded"
3572        );
3573        assert_eq!(e["turn"], 0);
3574        assert_eq!(e["input_tokens"], 100);
3575        assert_eq!(e["output_tokens"], 50);
3576        assert_eq!(e["decision"], "tool_call");
3577        assert_eq!(e["tool_name"], "bash");
3578        // Confirm snake_case (not camelCase)
3579        assert!(e.get("inputTokens").is_none(), "must use snake_case");
3580        assert!(e.get("toolName").is_none(), "must use snake_case");
3581        // No driver reported usage, so no key claims one was reported.
3582        for key in [
3583            "input_tokens_actual",
3584            "output_tokens_actual",
3585            "cached_tokens",
3586            "cache_write_tokens",
3587        ] {
3588            assert!(e.get(key).is_none(), "{key} must be omitted, not null or 0");
3589        }
3590    }
3591
3592    /// A partially-populated `usage` writes only the members the driver reported: a member it
3593    /// left out stays off the line entirely rather than being recorded as a zero.
3594    #[tokio::test]
3595    async fn inference_writes_only_the_reported_usage_members() {
3596        let dir = tempfile::tempdir().unwrap();
3597        let mut w = make_writer(dir.path()).await;
3598        let usage = DriverUsage {
3599            input_tokens: Some(12043),
3600            output_tokens: None,
3601            cached_tokens: Some(0),
3602            cache_write_tokens: None,
3603        };
3604        w.write_inference(
3605            0,
3606            100,
3607            50,
3608            "end_turn".to_string(),
3609            Some("end_turn"),
3610            None,
3611            None,
3612            Some(&usage),
3613            Vec::new(),
3614            None,
3615        )
3616        .await
3617        .unwrap();
3618        w.flush().await.unwrap();
3619
3620        let events = read_events(dir.path());
3621        let e = &events[0];
3622        assert_eq!(e["input_tokens"], 100, "the estimate is untouched");
3623        assert_eq!(e["input_tokens_actual"], 12043);
3624        assert_eq!(e["cached_tokens"], 0, "a reported zero is written");
3625        assert!(e.get("output_tokens_actual").is_none());
3626        assert!(e.get("cache_write_tokens").is_none());
3627    }
3628
3629    #[tokio::test]
3630    async fn tool_call_fields() {
3631        let dir = tempfile::tempdir().unwrap();
3632        let mut w = make_writer(dir.path()).await;
3633        w.write_tool_call(
3634            1,
3635            "bash".to_string(),
3636            None,
3637            serde_json::json!({"command": "echo hi"}),
3638            42,
3639            "hi\n",
3640            100,
3641            55,
3642            "ok".to_string(),
3643            None,
3644            None,
3645        )
3646        .await
3647        .unwrap();
3648        w.flush().await.unwrap();
3649
3650        let events = read_events(dir.path());
3651        let e = &events[0];
3652        assert_eq!(e["event_type"], "tool_call");
3653        assert_eq!(e["turn"], 1);
3654        assert_eq!(e["tool_name"], "bash");
3655        assert_eq!(e["input_bytes"], 42);
3656        assert_eq!(e["output_bytes"], 100);
3657        assert_eq!(e["duration_ms"], 55);
3658        assert_eq!(e["status"], "ok");
3659        assert!(
3660            e.get("state_effect").is_none(),
3661            "state_effect must be omitted when the tool declared nothing"
3662        );
3663    }
3664
3665    #[tokio::test]
3666    async fn tool_call_records_declared_state_effect() {
3667        let dir = tempfile::tempdir().unwrap();
3668        let mut w = make_writer(dir.path()).await;
3669        w.write_tool_call(
3670            0,
3671            "murmur-tool-editor".to_string(),
3672            None,
3673            serde_json::json!({"operation": "read_file", "path": "a.txt"}),
3674            10,
3675            "hi\n",
3676            3,
3677            5,
3678            "ok".to_string(),
3679            Some("read".to_string()),
3680            None,
3681        )
3682        .await
3683        .unwrap();
3684        w.flush().await.unwrap();
3685
3686        let events = read_events(dir.path());
3687        assert_eq!(
3688            events[0]["state_effect"], "read",
3689            "declared state_effect must be recorded verbatim on the tool_call event"
3690        );
3691    }
3692
3693    #[tokio::test]
3694    async fn tool_call_records_declared_resource_id() {
3695        let dir = tempfile::tempdir().unwrap();
3696        let mut w = make_writer(dir.path()).await;
3697        w.write_tool_call(
3698            0,
3699            "murmur-tool-code-graph".to_string(),
3700            None,
3701            serde_json::json!({"symbol": "Foo::bar"}),
3702            10,
3703            "hi\n",
3704            3,
3705            5,
3706            "ok".to_string(),
3707            Some("read".to_string()),
3708            Some("sym:Foo".to_string()),
3709        )
3710        .await
3711        .unwrap();
3712        w.flush().await.unwrap();
3713
3714        let events = read_events(dir.path());
3715        assert_eq!(
3716            events[0]["resource_id"], "sym:Foo",
3717            "declared resource_id must reach trace.jsonl verbatim, unparsed"
3718        );
3719    }
3720
3721    #[tokio::test]
3722    async fn tool_call_omits_undeclared_resource_id() {
3723        let dir = tempfile::tempdir().unwrap();
3724        let mut w = make_writer(dir.path()).await;
3725        w.write_tool_call(
3726            0,
3727            "bash".to_string(),
3728            None,
3729            serde_json::json!({"command": "echo hi"}),
3730            10,
3731            "hi\n",
3732            3,
3733            5,
3734            "ok".to_string(),
3735            None,
3736            None,
3737        )
3738        .await
3739        .unwrap();
3740        w.flush().await.unwrap();
3741
3742        let events = read_events(dir.path());
3743        assert!(
3744            events[0].get("resource_id").is_none(),
3745            "resource_id must be omitted entirely (not null) when the tool declared nothing"
3746        );
3747    }
3748
3749    #[tokio::test]
3750    async fn shell_fields() {
3751        let dir = tempfile::tempdir().unwrap();
3752        let mut w = make_writer(dir.path()).await;
3753        w.write_shell(
3754            0,
3755            "/usr/bin/echo".to_string(),
3756            "echo hi".to_string(),
3757            0,
3758            7,
3759            0,
3760            10,
3761            None,
3762        )
3763        .await
3764        .unwrap();
3765        w.flush().await.unwrap();
3766
3767        let events = read_events(dir.path());
3768        let e = &events[0];
3769        assert_eq!(e["event_type"], "shell");
3770        // `binary` names what ran; `command` still carries only the argument list, so
3771        // neither can be derived from the other.
3772        assert_eq!(e["binary"], "/usr/bin/echo");
3773        assert_eq!(e["command"], "echo hi");
3774        assert_eq!(e["exit_code"], 0);
3775        assert_eq!(e["stdout_bytes"], 7);
3776        assert_eq!(e["stderr_bytes"], 0);
3777        assert_eq!(e["duration_ms"], 10);
3778    }
3779
3780    #[tokio::test]
3781    async fn compaction_fields() {
3782        let dir = tempfile::tempdir().unwrap();
3783        let mut w = make_writer(dir.path()).await;
3784        w.write_compaction(3, 80000, 20000).await.unwrap();
3785        w.flush().await.unwrap();
3786
3787        let events = read_events(dir.path());
3788        let e = &events[0];
3789        assert_eq!(e["event_type"], "compaction");
3790        assert_eq!(e["turn"], 3);
3791        assert_eq!(e["tokens_before"], 80000);
3792        assert_eq!(e["tokens_after"], 20000);
3793    }
3794
3795    #[tokio::test]
3796    async fn hook_dispatch_error_fields() {
3797        let dir = tempfile::tempdir().unwrap();
3798        let mut w = make_writer(dir.path()).await;
3799        w.write_hook_dispatch_error("my-hook", "on-tool-call", "write-manifests")
3800            .await
3801            .unwrap();
3802        w.flush().await.unwrap();
3803
3804        let events = read_events(dir.path());
3805        let e = &events[0];
3806        assert_eq!(e["event_type"], "hook_dispatch_error");
3807        assert_eq!(e["session_id"], "test-session-id");
3808        assert_eq!(e["hook_name"], "my-hook");
3809        assert_eq!(e["event"], "on-tool-call");
3810        assert_eq!(e["arm"], "write-manifests");
3811        assert!(e["timestamp"].as_u64().unwrap() > 0);
3812        // snake_case checks
3813        assert!(e.get("hookName").is_none(), "must use snake_case");
3814    }
3815
3816    #[tokio::test]
3817    async fn session_end_fields_and_snake_case() {
3818        let dir = tempfile::tempdir().unwrap();
3819        let mut w = make_writer(dir.path()).await;
3820        w.write_inference(
3821            0,
3822            100,
3823            50,
3824            "tool_call".to_string(),
3825            Some("tool_call"),
3826            None,
3827            None,
3828            None,
3829            Vec::new(),
3830            None,
3831        )
3832        .await
3833        .unwrap();
3834        w.write_tool_call(
3835            0,
3836            "bash".to_string(),
3837            None,
3838            serde_json::json!({"command": "ls"}),
3839            10,
3840            "file.txt\n",
3841            20,
3842            5,
3843            "ok".to_string(),
3844            None,
3845            None,
3846        )
3847        .await
3848        .unwrap();
3849        w.write_shell(0, "/bin/ls".to_string(), "ls".to_string(), 0, 3, 0, 2, None)
3850            .await
3851            .unwrap();
3852        w.write_session_end("ok").await.unwrap();
3853        w.flush().await.unwrap();
3854
3855        let events = read_events(dir.path());
3856        let se = events.last().unwrap();
3857        assert_eq!(se["event_type"], "session_end");
3858        assert_eq!(se["total_turns"], 1);
3859        assert_eq!(se["total_input_tokens"], 100);
3860        assert_eq!(se["total_output_tokens"], 50);
3861        assert_eq!(se["total_tool_calls"], 1);
3862        assert_eq!(se["total_shell_calls"], 1);
3863        assert_eq!(se["exit_status"], "ok");
3864        assert!(se["duration_ms"].as_u64().is_some());
3865        // snake_case checks
3866        assert!(se.get("exitStatus").is_none(), "must use snake_case");
3867        assert!(se.get("totalTurns").is_none(), "must use snake_case");
3868    }
3869
3870    #[tokio::test]
3871    async fn write_session_end_if_not_ended_is_noop_when_already_ended() {
3872        let dir = tempfile::tempdir().unwrap();
3873        let mut w = make_writer(dir.path()).await;
3874        w.write_session_start(10, vec![]).await.unwrap();
3875        w.write_session_end("ok").await.unwrap();
3876        w.write_session_end_if_not_ended("failed").await.unwrap();
3877        w.flush().await.unwrap();
3878
3879        let events = read_events(dir.path());
3880        assert_eq!(
3881            events
3882                .iter()
3883                .filter(|e| e["event_type"] == "session_end")
3884                .count(),
3885            1,
3886            "session_end should appear exactly once"
3887        );
3888    }
3889
3890    #[tokio::test]
3891    async fn write_session_end_if_not_ended_writes_when_started_not_ended() {
3892        let dir = tempfile::tempdir().unwrap();
3893        let mut w = make_writer(dir.path()).await;
3894        w.write_session_start(10, vec![]).await.unwrap();
3895        // Simulate error path — session_end_if_not_ended in launch_session
3896        w.write_session_end_if_not_ended("failed").await.unwrap();
3897        w.flush().await.unwrap();
3898
3899        let events = read_events(dir.path());
3900        let se = events
3901            .iter()
3902            .find(|e| e["event_type"] == "session_end")
3903            .unwrap();
3904        assert_eq!(se["exit_status"], "failed");
3905    }
3906
3907    #[tokio::test]
3908    async fn no_session_end_written_when_session_never_started() {
3909        let dir = tempfile::tempdir().unwrap();
3910        let mut w = make_writer(dir.path()).await;
3911        // session_start never written — fallback should be no-op
3912        w.write_session_end_if_not_ended("failed").await.unwrap();
3913        w.flush().await.unwrap();
3914
3915        let events = read_events(dir.path());
3916        assert!(
3917            events.iter().all(|e| e["event_type"] != "session_end"),
3918            "session_end must not appear when session_start was never written"
3919        );
3920    }
3921
3922    #[tokio::test]
3923    async fn session_id_on_every_event() {
3924        let dir = tempfile::tempdir().unwrap();
3925        let mut w = make_writer(dir.path()).await;
3926        w.write_session_start(10, vec!["bash".to_string()])
3927            .await
3928            .unwrap();
3929        w.write_inference(
3930            0,
3931            10,
3932            5,
3933            "end_turn".to_string(),
3934            Some("end_turn"),
3935            None,
3936            None,
3937            None,
3938            Vec::new(),
3939            None,
3940        )
3941        .await
3942        .unwrap();
3943        w.write_session_end("ok").await.unwrap();
3944        w.flush().await.unwrap();
3945
3946        let events = read_events(dir.path());
3947        for e in &events {
3948            assert_eq!(e["session_id"], "test-session-id");
3949        }
3950    }
3951
3952    #[tokio::test]
3953    async fn counts_match_per_event_sum() {
3954        let dir = tempfile::tempdir().unwrap();
3955        let mut w = make_writer(dir.path()).await;
3956        w.write_session_start(10, vec![]).await.unwrap();
3957        w.write_inference(
3958            0,
3959            50,
3960            25,
3961            "tool_call".to_string(),
3962            Some("tool_call"),
3963            Some("bash".to_string()),
3964            None,
3965            None,
3966            vec!["msg_0000000000000000000000000000000a".to_string()],
3967            None,
3968        )
3969        .await
3970        .unwrap();
3971        w.write_tool_call(
3972            0,
3973            "bash".to_string(),
3974            None,
3975            serde_json::json!({"command": "echo"}),
3976            5,
3977            "ok",
3978            10,
3979            3,
3980            "ok".to_string(),
3981            None,
3982            None,
3983        )
3984        .await
3985        .unwrap();
3986        w.write_shell(
3987            0,
3988            "/bin/echo".to_string(),
3989            "echo".to_string(),
3990            0,
3991            4,
3992            0,
3993            1,
3994            None,
3995        )
3996        .await
3997        .unwrap();
3998        w.write_inference(
3999            1,
4000            60,
4001            30,
4002            "end_turn".to_string(),
4003            Some("end_turn"),
4004            None,
4005            None,
4006            None,
4007            Vec::new(),
4008            None,
4009        )
4010        .await
4011        .unwrap();
4012        w.write_session_end("ok").await.unwrap();
4013        w.flush().await.unwrap();
4014
4015        let events = read_events(dir.path());
4016        let inference_count = events
4017            .iter()
4018            .filter(|e| e["event_type"] == "inference")
4019            .count();
4020        let tool_count = events
4021            .iter()
4022            .filter(|e| e["event_type"] == "tool_call")
4023            .count();
4024        let shell_count = events.iter().filter(|e| e["event_type"] == "shell").count();
4025
4026        let se = events
4027            .iter()
4028            .find(|e| e["event_type"] == "session_end")
4029            .unwrap();
4030        assert_eq!(
4031            se["total_turns"].as_u64().unwrap() as usize,
4032            inference_count
4033        );
4034        assert_eq!(
4035            se["total_tool_calls"].as_u64().unwrap() as usize,
4036            tool_count
4037        );
4038        assert_eq!(
4039            se["total_shell_calls"].as_u64().unwrap() as usize,
4040            shell_count
4041        );
4042    }
4043
4044    #[tokio::test]
4045    async fn tool_call_input_always_present() {
4046        let dir = tempfile::tempdir().unwrap();
4047        let mut w = make_writer(dir.path()).await; // TraceCapture::Meta
4048        w.write_tool_call(
4049            0,
4050            "bash".to_string(),
4051            None,
4052            serde_json::json!({"command": "ls -la"}),
4053            20,
4054            "total 8\n",
4055            80,
4056            12,
4057            "ok".to_string(),
4058            None,
4059            None,
4060        )
4061        .await
4062        .unwrap();
4063        w.flush().await.unwrap();
4064
4065        let events = read_events(dir.path());
4066        let e = &events[0];
4067        assert_eq!(e["event_type"], "tool_call");
4068        assert_eq!(e["input"]["command"], "ls -la");
4069        assert!(e.get("input").is_some(), "input must always be present");
4070    }
4071
4072    #[tokio::test]
4073    async fn tool_call_output_absent_by_default() {
4074        let dir = tempfile::tempdir().unwrap();
4075        let mut w = make_writer(dir.path()).await; // TraceCapture::Meta
4076        w.write_tool_call(
4077            0,
4078            "bash".to_string(),
4079            None,
4080            serde_json::json!({"command": "ls"}),
4081            10,
4082            "file.txt\n",
4083            90,
4084            8,
4085            "ok".to_string(),
4086            None,
4087            None,
4088        )
4089        .await
4090        .unwrap();
4091        w.flush().await.unwrap();
4092
4093        let events = read_events(dir.path());
4094        let e = &events[0];
4095        assert!(
4096            e.get("output").is_none(),
4097            "output must be absent under a capture mode that stores no bodies"
4098        );
4099        assert_eq!(e["output_bytes"], 90, "output_bytes must still be recorded");
4100    }
4101
4102    #[tokio::test]
4103    async fn skill_call_writes_skill_call_event() {
4104        let dir = tempfile::tempdir().unwrap();
4105        let mut w = make_writer(dir.path()).await;
4106        w.write_skill_call(2, "my-skill".to_string(), 1024, 8, "ok".to_string())
4107            .await
4108            .unwrap();
4109        w.flush().await.unwrap();
4110
4111        let events = read_events(dir.path());
4112        let e = &events[0];
4113        assert_eq!(e["event_type"], "skill_call");
4114        assert_eq!(e["turn"], 2);
4115        assert_eq!(e["skill_name"], "my-skill");
4116        assert_eq!(e["output_bytes"], 1024);
4117        assert_eq!(e["duration_ms"], 8);
4118        assert_eq!(e["status"], "ok");
4119    }
4120
4121    #[tokio::test]
4122    async fn skill_call_does_not_increment_total_tool_calls() {
4123        let dir = tempfile::tempdir().unwrap();
4124        let mut w = make_writer(dir.path()).await;
4125        w.write_session_start(10, vec![]).await.unwrap();
4126        w.write_tool_call(
4127            0,
4128            "real-tool".to_string(),
4129            None,
4130            serde_json::json!({}),
4131            2,
4132            "data",
4133            4,
4134            5,
4135            "ok".to_string(),
4136            None,
4137            None,
4138        )
4139        .await
4140        .unwrap();
4141        w.write_skill_call(1, "my-skill".to_string(), 512, 3, "ok".to_string())
4142            .await
4143            .unwrap();
4144        w.write_session_end("ok").await.unwrap();
4145        w.flush().await.unwrap();
4146
4147        let events = read_events(dir.path());
4148        let se = events
4149            .iter()
4150            .find(|e| e["event_type"] == "session_end")
4151            .unwrap();
4152        assert_eq!(
4153            se["total_tool_calls"], 1,
4154            "skill_call must not inflate total_tool_calls"
4155        );
4156    }
4157
4158    #[tokio::test]
4159    async fn tool_call_output_present_when_opted_in() {
4160        let dir = tempfile::tempdir().unwrap();
4161        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
4162        w.write_tool_call(
4163            0,
4164            "bash".to_string(),
4165            None,
4166            serde_json::json!({"command": "echo hello"}),
4167            22,
4168            "hello\n",
4169            60,
4170            5,
4171            "ok".to_string(),
4172            None,
4173            None,
4174        )
4175        .await
4176        .unwrap();
4177        w.flush().await.unwrap();
4178
4179        let events = read_events(dir.path());
4180        let e = &events[0];
4181        assert_eq!(
4182            e["output"].as_str().unwrap(),
4183            "hello\n",
4184            "output must be present under capture: content"
4185        );
4186        assert_eq!(e["output_bytes"], 60);
4187    }
4188
4189    #[tokio::test]
4190    async fn task_end_carries_reopen_count() {
4191        let dir = tempfile::tempdir().unwrap();
4192        let mut w = make_writer(dir.path()).await;
4193        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4194            .await
4195            .unwrap();
4196        w.write_task_end("tsk_1", "ok", 2).await.unwrap();
4197        w.flush().await.unwrap();
4198
4199        let events = read_events(dir.path());
4200        let end = events
4201            .iter()
4202            .find(|e| e["event_type"] == "task_end")
4203            .unwrap();
4204        assert_eq!(end["task_id"], "tsk_1");
4205        assert_eq!(end["exit_status"], "ok");
4206        assert_eq!(end["reopen_count"], 2);
4207    }
4208
4209    #[tokio::test]
4210    async fn task_reopened_names_hook_reason_and_ordinal() {
4211        let dir = tempfile::tempdir().unwrap();
4212        let mut w = make_writer(dir.path()).await;
4213        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4214            .await
4215            .unwrap();
4216        w.write_task_reopened("tsk_1", "gatekeeper", "tests still fail", 1)
4217            .await
4218            .unwrap();
4219        w.flush().await.unwrap();
4220
4221        let events = read_events(dir.path());
4222        let re = events
4223            .iter()
4224            .find(|e| e["event_type"] == "task_reopened")
4225            .unwrap();
4226        assert_eq!(re["task_id"], "tsk_1");
4227        assert_eq!(re["hook_name"], "gatekeeper");
4228        assert_eq!(re["reason"], "tests still fail");
4229        assert_eq!(re["reopen_number"], 1);
4230        assert!(re["timestamp"].as_u64().unwrap() > 0);
4231    }
4232
4233    /// `task_turns()` reports the cumulative per-task turn count the reopen loop reads to
4234    /// compute the remaining `max_turns` budget: it advances with each `write_inference`
4235    /// and resets on `write_task_start`.
4236    #[tokio::test]
4237    async fn task_turns_accessor_tracks_and_resets() {
4238        let dir = tempfile::tempdir().unwrap();
4239        let mut w = make_writer(dir.path()).await;
4240        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4241            .await
4242            .unwrap();
4243        assert_eq!(w.task_turns(), 0);
4244        w.write_inference(
4245            0,
4246            10,
4247            5,
4248            "end_turn".to_string(),
4249            Some("end_turn"),
4250            None,
4251            None,
4252            None,
4253            Vec::new(),
4254            None,
4255        )
4256        .await
4257        .unwrap();
4258        w.write_inference(
4259            1,
4260            10,
4261            5,
4262            "end_turn".to_string(),
4263            Some("end_turn"),
4264            None,
4265            None,
4266            None,
4267            Vec::new(),
4268            None,
4269        )
4270        .await
4271        .unwrap();
4272        assert_eq!(w.task_turns(), 2);
4273        // A new task resets the per-task counter.
4274        w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), None, 3)
4275            .await
4276            .unwrap();
4277        assert_eq!(w.task_turns(), 0);
4278    }
4279
4280    // ── Event identity and parenting ──────────────────────────────────────────
4281
4282    /// The full tree one task produces, walked the way a reader walks it: every line is
4283    /// identified, every non-null `parent_id` names a line already written, and the shape is
4284    /// session → task → turn → {tool_call, shell}.
4285    #[tokio::test]
4286    async fn every_event_carries_a_unique_id_and_parents_to_an_earlier_event() {
4287        let dir = tempfile::tempdir().unwrap();
4288        let mut w = make_writer(dir.path()).await;
4289        w.write_session_start(10, vec!["bash".to_string()])
4290            .await
4291            .unwrap();
4292        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4293            .await
4294            .unwrap();
4295        w.write_inference(
4296            0,
4297            10,
4298            5,
4299            "tool_call".to_string(),
4300            Some("tool_call"),
4301            None,
4302            None,
4303            None,
4304            Vec::new(),
4305            None,
4306        )
4307        .await
4308        .unwrap();
4309        w.write_tool_call(
4310            0,
4311            "bash".to_string(),
4312            Some("toolu_1".to_string()),
4313            serde_json::json!({"command": "echo hi"}),
4314            10,
4315            "hi\n",
4316            3,
4317            5,
4318            "ok".to_string(),
4319            None,
4320            None,
4321        )
4322        .await
4323        .unwrap();
4324        w.write_shell(
4325            0,
4326            "/bin/bash".to_string(),
4327            "echo hi".to_string(),
4328            0,
4329            3,
4330            0,
4331            2,
4332            None,
4333        )
4334        .await
4335        .unwrap();
4336        w.write_task_end("tsk_1", "ok", 0).await.unwrap();
4337        w.write_session_end("ok").await.unwrap();
4338        w.flush().await.unwrap();
4339
4340        let events = read_events(dir.path());
4341        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
4342        for (i, event) in events.iter().enumerate() {
4343            let id = event["event_id"].as_str().unwrap();
4344            assert!(id.starts_with("evt_") && id.len() == 36, "event {i}: {id}");
4345            assert!(id[4..].chars().all(|c| c.is_ascii_hexdigit()), "event {i}");
4346            assert!(seen.insert(id.to_string()), "event {i} reuses {id}");
4347            assert!(
4348                event.get("parent_id").is_some(),
4349                "event {i} has no parent_id"
4350            );
4351            if let Some(parent) = event["parent_id"].as_str() {
4352                assert!(seen.contains(parent), "event {i} names dangling {parent}");
4353            }
4354        }
4355
4356        let by_type = |ty: &str| {
4357            events
4358                .iter()
4359                .find(|e| e["event_type"] == ty)
4360                .unwrap_or_else(|| panic!("no {ty}"))
4361                .clone()
4362        };
4363        let session = by_type("session_start");
4364        assert!(session["parent_id"].is_null());
4365        assert_eq!(session["event_id"], w.session_event_id());
4366
4367        let task = by_type("task_start");
4368        assert_eq!(task["parent_id"], session["event_id"]);
4369
4370        let turn = by_type("inference");
4371        assert_eq!(turn["parent_id"], task["event_id"]);
4372
4373        assert_eq!(by_type("tool_call")["parent_id"], turn["event_id"]);
4374        assert_eq!(by_type("shell")["parent_id"], turn["event_id"]);
4375        assert_eq!(by_type("task_end")["parent_id"], task["event_id"]);
4376        assert_eq!(by_type("session_end")["parent_id"], session["event_id"]);
4377    }
4378
4379    /// A hook's `run-inference` record is a product of the turn it ran inside, not a turn of
4380    /// its own: it parents to the agent loop's inference and never replaces it as the node the
4381    /// turn's other events hang off.
4382    #[tokio::test]
4383    async fn hook_origin_inference_hangs_off_the_turn_rather_than_becoming_one() {
4384        let dir = tempfile::tempdir().unwrap();
4385        let mut w = make_writer(dir.path()).await;
4386        w.write_session_start(10, Vec::new()).await.unwrap();
4387        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4388            .await
4389            .unwrap();
4390        w.write_inference(
4391            0,
4392            10,
4393            5,
4394            "tool_call".to_string(),
4395            Some("tool_call"),
4396            None,
4397            None,
4398            None,
4399            Vec::new(),
4400            None,
4401        )
4402        .await
4403        .unwrap();
4404        let origin = InferenceOrigin {
4405            source: "hook:gatekeeper".to_string(),
4406            model: "claude-test".to_string(),
4407        };
4408        w.write_inference(
4409            0,
4410            1,
4411            1,
4412            "end_turn".to_string(),
4413            // A hook's `run-inference` saw no provider stop reason, so it records none.
4414            None,
4415            None,
4416            Some(&origin),
4417            None,
4418            Vec::new(),
4419            None,
4420        )
4421        .await
4422        .unwrap();
4423        w.flush().await.unwrap();
4424
4425        let events = read_events(dir.path());
4426        let turn = &events[2];
4427        let hook = &events[3];
4428        assert!(turn["origin"].is_null());
4429        assert_eq!(hook["origin"], "hook:gatekeeper");
4430        assert_eq!(hook["parent_id"], turn["event_id"]);
4431        // The hook sent a message list the runtime never minted, so the key is absent rather
4432        // than empty.
4433        assert!(hook.get("message_ids").is_none(), "{hook}");
4434    }
4435
4436    /// A writer with no session frame behind it names no parent. This is the script-capsule
4437    /// `a2a_send` drain, which opens a writer purely to flush buffered sends into a file that
4438    /// has no `session_start` line — naming one would dangle.
4439    #[tokio::test]
4440    async fn a2a_send_parents_to_null_without_a_session_frame_and_to_it_with_one() {
4441        let bare = tempfile::tempdir().unwrap();
4442        let mut w = make_writer(bare.path()).await;
4443        w.write_a2a_send(
4444            "http://peer",
4445            "msg_1",
4446            "tsk_1",
4447            "ctx_1",
4448            None,
4449            TrustClass::Untrusted,
4450        )
4451        .await
4452        .unwrap();
4453        w.flush().await.unwrap();
4454        assert!(read_events(bare.path())[0]["parent_id"].is_null());
4455
4456        let framed = tempfile::tempdir().unwrap();
4457        let mut w = make_writer(framed.path()).await;
4458        w.write_session_start(1, Vec::new()).await.unwrap();
4459        w.write_a2a_send(
4460            "http://peer",
4461            "msg_1",
4462            "tsk_1",
4463            "ctx_1",
4464            None,
4465            TrustClass::Untrusted,
4466        )
4467        .await
4468        .unwrap();
4469        w.flush().await.unwrap();
4470        let events = read_events(framed.path());
4471        assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
4472    }
4473
4474    /// Turn-level events carry the enclosing task's id, and `null` once the task has ended —
4475    /// `task_id` is a record of scope, not a value the writer holds on to.
4476    #[tokio::test]
4477    async fn turn_level_events_carry_the_active_task_id() {
4478        let dir = tempfile::tempdir().unwrap();
4479        let mut w = make_writer(dir.path()).await;
4480        w.write_session_start(10, Vec::new()).await.unwrap();
4481        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4482            .await
4483            .unwrap();
4484        w.write_inference(
4485            0,
4486            10,
4487            5,
4488            "tool_call".to_string(),
4489            Some("tool_call"),
4490            None,
4491            None,
4492            None,
4493            Vec::new(),
4494            None,
4495        )
4496        .await
4497        .unwrap();
4498        w.write_skill_call(0, "house-style".to_string(), 12, 1, "ok".to_string())
4499            .await
4500            .unwrap();
4501        w.write_compaction(0, 100, 40).await.unwrap();
4502        w.write_task_end("tsk_1", "ok", 0).await.unwrap();
4503        w.write_inference(
4504            1,
4505            1,
4506            1,
4507            "end_turn".to_string(),
4508            Some("end_turn"),
4509            None,
4510            None,
4511            None,
4512            Vec::new(),
4513            None,
4514        )
4515        .await
4516        .unwrap();
4517        w.flush().await.unwrap();
4518
4519        let events = read_events(dir.path());
4520        for event in events
4521            .iter()
4522            .filter(|e| {
4523                matches!(
4524                    e["event_type"].as_str(),
4525                    Some("inference" | "skill_call" | "compaction")
4526                )
4527            })
4528            .take(3)
4529        {
4530            assert_eq!(event["task_id"], "tsk_1", "{event}");
4531        }
4532        let after = events.last().unwrap();
4533        assert_eq!(after["event_type"], "inference");
4534        assert!(
4535            after["task_id"].is_null(),
4536            "an inference outside any task carries a null task_id, not the last task's"
4537        );
4538        // Once the task has ended, its events fall back to the session node.
4539        assert_eq!(after["parent_id"], events[0]["event_id"]);
4540    }
4541
4542    /// The provider's id is recorded verbatim; an absent or empty one records as `null`, never
4543    /// as an id the provider never issued.
4544    #[tokio::test]
4545    async fn tool_call_records_the_provider_id_verbatim_and_empty_as_null() {
4546        for (given, expected) in [
4547            (Some("toolu_1".to_string()), Some("toolu_1")),
4548            (Some(String::new()), None),
4549            (None, None),
4550        ] {
4551            let dir = tempfile::tempdir().unwrap();
4552            let mut w = make_writer(dir.path()).await;
4553            w.write_tool_call(
4554                0,
4555                "bash".to_string(),
4556                given.clone(),
4557                serde_json::json!({}),
4558                2,
4559                "",
4560                0,
4561                1,
4562                "ok".to_string(),
4563                None,
4564                None,
4565            )
4566            .await
4567            .unwrap();
4568            w.flush().await.unwrap();
4569            let e = &read_events(dir.path())[0];
4570            match expected {
4571                Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
4572                None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
4573            }
4574        }
4575    }
4576
4577    /// A declined compaction is a full record of the decline, not a bare marker: it names the
4578    /// turn, the task, the occupancy the session went on running over, and the reason.
4579    #[tokio::test]
4580    async fn compaction_declined_records_turn_task_tokens_and_reason() {
4581        let dir = tempfile::tempdir().unwrap();
4582        let mut w = make_writer(dir.path()).await;
4583        w.write_session_start(10, Vec::new()).await.unwrap();
4584        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4585            .await
4586            .unwrap();
4587        w.write_inference(
4588            2,
4589            10,
4590            5,
4591            "tool_call".to_string(),
4592            Some("tool_call"),
4593            None,
4594            None,
4595            None,
4596            Vec::new(),
4597            None,
4598        )
4599        .await
4600        .unwrap();
4601        w.write_compaction_declined(2, 4321, COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL)
4602            .await
4603            .unwrap();
4604        w.flush().await.unwrap();
4605
4606        let events = read_events(dir.path());
4607        let declined: Vec<&Value> = events
4608            .iter()
4609            .filter(|e| e["event_type"] == "compaction_declined")
4610            .collect();
4611        assert_eq!(declined.len(), 1);
4612        let d = declined[0];
4613        assert_eq!(d["reason"], "unresolved_tool_call");
4614        assert_eq!(d["turn"], 2);
4615        assert_eq!(d["task_id"], "tsk_1");
4616        assert_eq!(d["tokens"], 4321);
4617        assert!(d["event_id"].as_str().unwrap().starts_with("evt_"));
4618        assert_eq!(
4619            d["parent_id"],
4620            events
4621                .iter()
4622                .find(|e| e["event_type"] == "inference")
4623                .unwrap()["event_id"]
4624        );
4625    }
4626
4627    /// `evt_` ids sort by mint time and carry their own millisecond timestamp, the same
4628    /// property `mur trace list --since` reads off a `ses_` id.
4629    #[tokio::test]
4630    async fn event_ids_sort_by_mint_time() {
4631        let dir = tempfile::tempdir().unwrap();
4632        let mut w = make_writer(dir.path()).await;
4633        for turn in 0..5 {
4634            w.write_inference(
4635                turn,
4636                1,
4637                1,
4638                "end_turn".to_string(),
4639                Some("end_turn"),
4640                None,
4641                None,
4642                None,
4643                Vec::new(),
4644                None,
4645            )
4646            .await
4647            .unwrap();
4648        }
4649        w.flush().await.unwrap();
4650
4651        let ids: Vec<String> = read_events(dir.path())
4652            .iter()
4653            .map(|e| e["event_id"].as_str().unwrap().to_string())
4654            .collect();
4655        let mut sorted = ids.clone();
4656        sorted.sort();
4657        assert_eq!(ids, sorted, "evt_ ids must sort into mint order");
4658    }
4659
4660    // ── Wire capture: hashes, blobs and the capture gate ─────────────────────
4661
4662    /// A payload in the shape `build_driver_payload` produces, with `n` messages.
4663    fn wire_payload(system: &str, messages: usize) -> Value {
4664        let messages: Vec<Value> = (0..messages)
4665            .map(|i| serde_json::json!({"role": "user", "content": format!("m{i}")}))
4666            .collect();
4667        serde_json::json!({
4668            "model": "claude-test",
4669            "max_tokens": 1024,
4670            "messages": messages,
4671            "tools": [{"name": "bash"}],
4672            "params": {},
4673            "system": system,
4674        })
4675    }
4676
4677    async fn write_turn(w: &mut TraceWriter, turn: u32, payload: &Value, response: &str) {
4678        let wire = WireCapture::from_driver_payload(payload, response);
4679        w.write_inference(
4680            turn,
4681            10,
4682            5,
4683            "end_turn".to_string(),
4684            Some("end_turn"),
4685            None,
4686            None,
4687            None,
4688            vec!["msg_0000000000000000000000000000000a".to_string()],
4689            Some(&wire),
4690        )
4691        .await
4692        .unwrap();
4693    }
4694
4695    fn blob_path(dir: &std::path::Path, sha: &str) -> std::path::PathBuf {
4696        dir.join(BLOB_DIR_NAME).join(sha)
4697    }
4698
4699    /// Every hash an `inference` event names under `content` is the filename of a real file whose
4700    /// own digest is that filename — the whole point of the store.
4701    #[tokio::test]
4702    async fn content_capture_names_blobs_that_exist_and_rehash_to_their_names() {
4703        let dir = tempfile::tempdir().unwrap();
4704        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
4705        let payload = wire_payload("You are a capsule.", 3);
4706        write_turn(&mut w, 0, &payload, r#"{"stop_reason":"end_turn"}"#).await;
4707        w.flush().await.unwrap();
4708
4709        let e = &read_events(dir.path())[0];
4710        let mut named: Vec<String> = ["system_sha", "tools_sha", "response_sha"]
4711            .iter()
4712            .map(|key| {
4713                e[*key]
4714                    .as_str()
4715                    .unwrap_or_else(|| panic!("{key} missing from {e}"))
4716                    .to_string()
4717            })
4718            .collect();
4719        let shas = e["message_shas"].as_array().expect("message_shas");
4720        assert_eq!(shas.len(), 3);
4721        named.extend(shas.iter().map(|s| s.as_str().unwrap().to_string()));
4722
4723        for sha in &named {
4724            let path = blob_path(dir.path(), sha);
4725            let bytes =
4726                std::fs::read(&path).unwrap_or_else(|err| panic!("blob {sha} must exist: {err}"));
4727            assert_eq!(murmur_artifact::sha256_hex(&bytes), *sha);
4728            // A bare sha256 names content; `evt_`/`msg_`/`ses_` name entities. Neither a prefix
4729            // nor an extension may creep onto a blob name.
4730            assert_eq!(sha.len(), 64, "{sha}");
4731            assert!(
4732                sha.chars()
4733                    .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
4734                "{sha}"
4735            );
4736        }
4737    }
4738
4739    /// The hashed system body is the payload's `system` string, as text — so the blob reads as a
4740    /// prompt rather than as a JSON-quoted one.
4741    #[tokio::test]
4742    async fn the_system_blob_holds_the_prompt_as_readable_text() {
4743        let dir = tempfile::tempdir().unwrap();
4744        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
4745        let payload = wire_payload("[Capsule] you are cap.\nBe terse.", 1);
4746        write_turn(&mut w, 0, &payload, "{}").await;
4747        w.flush().await.unwrap();
4748
4749        let sha = read_events(dir.path())[0]["system_sha"]
4750            .as_str()
4751            .unwrap()
4752            .to_string();
4753        assert_eq!(
4754            std::fs::read_to_string(blob_path(dir.path(), &sha)).unwrap(),
4755            "[Capsule] you are cap.\nBe terse."
4756        );
4757    }
4758
4759    /// `meta` is the default and hashes exactly what `content` hashes; the difference is only
4760    /// whether the bodies are on disk.
4761    #[tokio::test]
4762    async fn meta_writes_the_same_hashes_and_no_bodies() {
4763        let payload = wire_payload("You are a capsule.", 2);
4764        let response = r#"{"stop_reason":"end_turn"}"#;
4765
4766        let meta = tempfile::tempdir().unwrap();
4767        let mut w = make_writer_with_opts(meta.path(), TraceCapture::Meta).await;
4768        write_turn(&mut w, 0, &payload, response).await;
4769        w.flush().await.unwrap();
4770
4771        let content = tempfile::tempdir().unwrap();
4772        let mut w = make_writer_with_opts(content.path(), TraceCapture::Content).await;
4773        write_turn(&mut w, 0, &payload, response).await;
4774        w.flush().await.unwrap();
4775
4776        let meta_event = read_events(meta.path()).remove(0);
4777        let content_event = read_events(content.path()).remove(0);
4778        for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
4779            assert_eq!(meta_event[key], content_event[key], "{key}");
4780        }
4781        assert!(
4782            !meta.path().join(BLOB_DIR_NAME).exists(),
4783            "meta must not create the blob directory"
4784        );
4785        assert!(content.path().join(BLOB_DIR_NAME).is_dir());
4786    }
4787
4788    /// The default a writer gets when the manifest declares no `trace:` block.
4789    #[tokio::test]
4790    async fn the_default_capture_mode_is_meta() {
4791        assert_eq!(TraceCapture::default(), TraceCapture::Meta);
4792        let dir = tempfile::tempdir().unwrap();
4793        let mut w = make_writer_with_prompt(dir.path(), TraceCapture::default(), None, false).await;
4794        write_turn(&mut w, 0, &wire_payload("s", 1), "{}").await;
4795        w.flush().await.unwrap();
4796
4797        let e = &read_events(dir.path())[0];
4798        assert!(e.get("system_sha").is_some());
4799        assert!(!dir.path().join(BLOB_DIR_NAME).exists());
4800    }
4801
4802    /// Under `none` not one of the four hash keys is present, and every other field is.
4803    #[tokio::test]
4804    async fn none_omits_every_hash_and_leaves_the_rest_of_the_event_intact() {
4805        let payload = wire_payload("You are a capsule.", 2);
4806
4807        let off = tempfile::tempdir().unwrap();
4808        let mut w = make_writer_with_opts(off.path(), TraceCapture::None).await;
4809        write_turn(&mut w, 3, &payload, "{}").await;
4810        w.flush().await.unwrap();
4811
4812        let e = read_events(off.path()).remove(0);
4813        for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
4814            assert!(
4815                e.get(key).is_none(),
4816                "{key} must be absent under none, got {e}"
4817            );
4818        }
4819        assert!(!off.path().join(BLOB_DIR_NAME).exists());
4820        for key in [
4821            "event_type",
4822            "event_id",
4823            "parent_id",
4824            "session_id",
4825            "timestamp",
4826            "turn",
4827            "task_id",
4828            "input_tokens",
4829            "output_tokens",
4830            "decision",
4831            "tool_name",
4832            "message_ids",
4833        ] {
4834            assert!(
4835                e.get(key).is_some(),
4836                "{key} must survive under none, got {e}"
4837            );
4838        }
4839        assert_eq!(e["turn"], 3);
4840        assert_eq!(e["message_ids"].as_array().unwrap().len(), 1);
4841    }
4842
4843    /// A prompt that does not change across ten turns is one blob, written once and never
4844    /// rewritten — the file's bytes are replaced by hand after the first turn and must survive.
4845    #[tokio::test]
4846    async fn an_unchanged_system_prompt_is_stored_once() {
4847        let dir = tempfile::tempdir().unwrap();
4848        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
4849        let payload = wire_payload("You are a capsule.", 1);
4850        write_turn(&mut w, 0, &payload, "{}").await;
4851
4852        let sha = {
4853            w.flush().await.unwrap();
4854            read_events(dir.path())[0]["system_sha"]
4855                .as_str()
4856                .unwrap()
4857                .to_string()
4858        };
4859        let path = blob_path(dir.path(), &sha);
4860        std::fs::write(&path, b"sentinel").unwrap();
4861
4862        for turn in 1..10 {
4863            write_turn(&mut w, turn, &payload, "{}").await;
4864        }
4865        w.flush().await.unwrap();
4866
4867        let events = read_events(dir.path());
4868        assert_eq!(events.len(), 10);
4869        for e in &events {
4870            assert_eq!(e["system_sha"], sha.as_str());
4871        }
4872        assert_eq!(
4873            std::fs::read(&path).unwrap(),
4874            b"sentinel",
4875            "an existing blob is never rewritten"
4876        );
4877        let matching = std::fs::read_dir(dir.path().join(BLOB_DIR_NAME))
4878            .unwrap()
4879            .filter(|entry| entry.as_ref().unwrap().file_name() == sha.as_str())
4880            .count();
4881        assert_eq!(matching, 1, "exactly one file bears that name");
4882    }
4883
4884    /// A record written for a completion the runtime parsed no driver response for names no stop
4885    /// reason: the key is absent, never an empty string. Absence means "no driver response was
4886    /// parsed here", so a reader can tell it apart from a driver that reported nothing — which is
4887    /// recorded as `""`.
4888    #[tokio::test]
4889    async fn stop_reason_is_absent_when_no_driver_response_was_parsed() {
4890        let dir = tempfile::tempdir().unwrap();
4891        let mut w = make_writer(dir.path()).await;
4892        w.write_inference(
4893            0,
4894            10,
4895            5,
4896            "end_turn".to_string(),
4897            None,
4898            None,
4899            None,
4900            None,
4901            Vec::new(),
4902            None,
4903        )
4904        .await
4905        .unwrap();
4906        w.flush().await.unwrap();
4907
4908        let event = read_events(dir.path()).remove(0);
4909        assert!(
4910            event.get("stop_reason").is_none(),
4911            "stop_reason must be absent, got {event}"
4912        );
4913    }
4914
4915    /// A hook's `run-inference` sends a payload the runtime never built, so its record names no
4916    /// hashes — absent from the JSON, not empty strings.
4917    #[tokio::test]
4918    async fn a_hook_origin_record_carries_no_hashes() {
4919        let dir = tempfile::tempdir().unwrap();
4920        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
4921        let origin = InferenceOrigin {
4922            source: "hook:gatekeeper".to_string(),
4923            model: "claude-test".to_string(),
4924        };
4925        w.write_inference(
4926            0,
4927            1,
4928            1,
4929            "end_turn".to_string(),
4930            // A hook's `run-inference` saw no provider stop reason, so it records none.
4931            None,
4932            None,
4933            Some(&origin),
4934            None,
4935            Vec::new(),
4936            None,
4937        )
4938        .await
4939        .unwrap();
4940        w.flush().await.unwrap();
4941
4942        let e = read_events(dir.path()).remove(0);
4943        assert_eq!(e["origin"], "hook:gatekeeper");
4944        for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
4945            assert!(e.get(key).is_none(), "{key} must be absent, got {e}");
4946        }
4947        assert!(!dir.path().join(BLOB_DIR_NAME).exists());
4948    }
4949
4950    /// Two runs sharing a prefix agree on that prefix's `message_shas` and disagree from the
4951    /// changed message onwards — the divergence index is the first unequal position.
4952    #[tokio::test]
4953    async fn message_shas_locate_the_index_two_runs_diverge_at() {
4954        async fn shas_for(payload: &Value) -> Vec<String> {
4955            let dir = tempfile::tempdir().unwrap();
4956            let mut w = make_writer_with_opts(dir.path(), TraceCapture::Meta).await;
4957            write_turn(&mut w, 0, payload, "{}").await;
4958            w.flush().await.unwrap();
4959            read_events(dir.path())[0]["message_shas"]
4960                .as_array()
4961                .unwrap()
4962                .iter()
4963                .map(|s| s.as_str().unwrap().to_string())
4964                .collect()
4965        }
4966
4967        let first = wire_payload("You are a capsule.", 4);
4968        let mut second = first.clone();
4969        second["messages"][2]["content"] = serde_json::json!("diverged");
4970
4971        let a = shas_for(&first).await;
4972        let b = shas_for(&second).await;
4973        assert_eq!(a.len(), 4);
4974        let divergence = a.iter().zip(&b).position(|(x, y)| x != y);
4975        assert_eq!(divergence, Some(2));
4976        assert_eq!(a[3], b[3], "only the changed message changes its own sha");
4977    }
4978
4979    /// A message repeated inside one request hashes to one name and one file. Ids never repeat;
4980    /// shas repeat exactly when content does, and that is what makes them comparable.
4981    #[tokio::test]
4982    async fn identical_messages_share_one_sha_and_one_blob() {
4983        let dir = tempfile::tempdir().unwrap();
4984        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
4985        let payload = serde_json::json!({
4986            "messages": [{"role": "user", "content": "same"}, {"role": "user", "content": "same"}],
4987            "tools": [],
4988            "system": "s",
4989        });
4990        write_turn(&mut w, 0, &payload, "{}").await;
4991        w.flush().await.unwrap();
4992
4993        let shas = read_events(dir.path())[0]["message_shas"]
4994            .as_array()
4995            .unwrap()
4996            .iter()
4997            .map(|s| s.as_str().unwrap().to_string())
4998            .collect::<Vec<_>>();
4999        assert_eq!(shas[0], shas[1]);
5000        assert!(blob_path(dir.path(), &shas[0]).is_file());
5001    }
5002
5003    /// Blobs are the bytes as sent, so a peer handle a tool call would have had redacted out of
5004    /// its summary stays in the body a `content` capture stores.
5005    #[tokio::test]
5006    async fn a_message_blob_is_the_body_verbatim() {
5007        let dir = tempfile::tempdir().unwrap();
5008        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
5009        let message = serde_json::json!({"role": "user", "content": "hello"});
5010        let payload = serde_json::json!({
5011            "messages": [message.clone()],
5012            "tools": [],
5013            "system": "s",
5014        });
5015        write_turn(&mut w, 0, &payload, "{}").await;
5016        w.flush().await.unwrap();
5017
5018        let sha = read_events(dir.path())[0]["message_shas"][0]
5019            .as_str()
5020            .unwrap()
5021            .to_string();
5022        assert_eq!(
5023            std::fs::read(blob_path(dir.path(), &sha)).unwrap(),
5024            serde_json::to_vec(&message).unwrap()
5025        );
5026    }
5027
5028    /// A delegation record a plan's `capsule` step wrote and one `delegate-task` wrote are the
5029    /// same record.
5030    ///
5031    /// Identical key sets and identical values on everything but the two fields that are
5032    /// per-line by construction — the event id and the clock. That is what makes a trace
5033    /// unable to say which surface launched a child, and it holds because both appenders
5034    /// serialize the same struct rather than because this test watches them.
5035    #[tokio::test]
5036    async fn a_plan_written_delegation_record_matches_a_delegate_task_one() {
5037        let plan_dir = tempfile::tempdir().unwrap();
5038        let tool_dir = tempfile::tempdir().unwrap();
5039        let session = "ses_0000000000004000800000000000plan".to_string();
5040        let node = "evt_0000000000004000800000000000node".to_string();
5041
5042        let plan = PlanTraceAppender::open(plan_dir.path(), session.clone(), node.clone()).unwrap();
5043        let tool = ResourceTraceAppender::open(tool_dir.path(), session.clone(), node.clone())
5044            .await
5045            .unwrap();
5046
5047        plan.write_delegation_start("dlg_abc123", "worker", "0.1.0", "ses_child", ".murmur/c");
5048        tool.write_delegation_start("dlg_abc123", "worker", "0.1.0", "ses_child", ".murmur/c")
5049            .await;
5050        plan.write_delegation(
5051            "worker",
5052            "0.1.0",
5053            Some("dlg_abc123".to_string()),
5054            Some("ses_child".to_string()),
5055            42,
5056            "completed",
5057            None,
5058        );
5059        tool.write_delegation(
5060            "worker",
5061            "0.1.0",
5062            Some("dlg_abc123".to_string()),
5063            Some("ses_child".to_string()),
5064            42,
5065            "completed",
5066            None,
5067        )
5068        .await;
5069
5070        let read = |dir: &Path| -> Vec<serde_json::Map<String, Value>> {
5071            std::fs::read_to_string(dir.join("trace.jsonl"))
5072                .unwrap()
5073                .lines()
5074                .map(|line| serde_json::from_str::<serde_json::Map<_, _>>(line).unwrap())
5075                .collect()
5076        };
5077        let from_plan = read(plan_dir.path());
5078        let from_tool = read(tool_dir.path());
5079        assert_eq!(from_plan.len(), 2);
5080        assert_eq!(from_tool.len(), 2);
5081
5082        for (planned, called, event_type) in [
5083            (&from_plan[0], &from_tool[0], "delegation_start"),
5084            (&from_plan[1], &from_tool[1], "delegation"),
5085        ] {
5086            assert_eq!(planned["event_type"], event_type);
5087            assert_eq!(called["event_type"], event_type);
5088            assert_eq!(
5089                planned.keys().collect::<Vec<_>>(),
5090                called.keys().collect::<Vec<_>>(),
5091                "{planned:?} vs {called:?}"
5092            );
5093            assert_eq!(planned["parent_id"], node.as_str());
5094            assert_eq!(called["parent_id"], node.as_str());
5095            for (key, value) in planned {
5096                if key == "event_id" || key == "timestamp" {
5097                    continue;
5098                }
5099                assert_eq!(value, &called[key], "{key}: {planned:?} vs {called:?}");
5100            }
5101            for key in ["plan_id", "step_id", "surface", "kind"] {
5102                assert!(!planned.contains_key(key), "{key} is on {planned:?}");
5103                assert!(!called.contains_key(key), "{key} is on {called:?}");
5104            }
5105        }
5106    }
5107}