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