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