Skip to main content

capsule_runtime/
trace.rs

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