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, OccupancyBasis},
13    containment::ScopeReport,
14    credential_gateway::GatewayOffsets,
15    formation::FormationId,
16    gateway_credential::CredentialChange,
17    inference_retry::{InferenceRetryRecord, RetryNote, RetryStop},
18    lanes::TaskLane,
19    origin::{TaskProvenance, TrustClass},
20    trace_blobs::BlobStore,
21    types::InstalledArtifactSummary,
22    workdir_fs::{WorkdirRoot, DEFAULT_FILE_MODE},
23};
24
25/// A session's trace file, directly under its session root.
26pub(crate) const TRACE_FILE_NAME: &str = "trace.jsonl";
27
28pub(crate) struct TraceWriter {
29    writer: BufWriter<File>,
30    session_id: String,
31    capsule_name: String,
32    capsule_version: String,
33    model: String,
34    capabilities: Vec<String>,
35    /// The session's complete effective grant set, computed once at stage time. Written whole to
36    /// `session_start` as `effective_grants`, and also the source of that event's
37    /// `containment_declared`/`containment_achieved`/`workdir_exec` summary fields — they are read
38    /// off this report rather than passed in beside it, so a trace cannot claim one containment
39    /// class at the top level and another inside the report.
40    effective_grants: ScopeReport,
41    /// How much of each turn's driver request this trace keeps — see [`TraceCapture`]. Decides
42    /// whether an `inference` event carries content hashes at all, whether the bodies behind
43    /// them reach [`Self::blobs`], and whether `tool_call.output` is written.
44    capture: TraceCapture,
45    /// The content-addressed bodies this session stored, at `<workdir>/blobs/<sha256>`. Only
46    /// written to under [`TraceCapture::Content`]; the directory is created on the first blob.
47    blobs: BlobStore,
48    /// Where the effective system prompt came from: `"manifest"`, `"cli"` or `"none"`. Derived
49    /// once in [`TraceWriter::open`] from the resolved prompt and the override flag, then repeated
50    /// on every `session_start` the same way `model` is — the prompt cannot change between the
51    /// tasks of one session.
52    system_prompt_source: &'static str,
53    /// SHA-256 (lowercase hex) of the resolved system prompt, or `None` when no prompt is in
54    /// effect. Always written to `session_start` (as `null` when absent) so a trace records *that*
55    /// a prompt was in effect and which one, even when the text itself is withheld.
56    system_prompt_sha256: Option<String>,
57    /// Where the inference credential came from: `"config"`, `"environment"`, `"manifest"` or
58    /// `"none"`. Set by [`Self::set_credential_source`] before `session_start` is written.
59    credential_source: &'static str,
60    /// Every credential gateway the session holds. Set by [`Self::set_gateways`] before
61    /// `session_start` is written; empty until then.
62    gateways: Vec<SessionGateway>,
63    /// What a controller may change on this session. Set by [`Self::set_control`] before
64    /// `session_start` is written; `None` for a capsule with no `control:` block.
65    control: Option<SessionControl>,
66    /// The driver choices. Set by [`Self::set_inference_choices`] before `session_start` is
67    /// written; empty for a capsule with no alternates.
68    inference_choices: Vec<SessionInferenceChoice>,
69    /// `inference.tool_refresh` as `ToolRefresh::wire_name` spells it, or `None` under
70    /// `transport: process`. Set by [`Self::set_tool_refresh`] before `session_start` is written.
71    tool_refresh: Option<&'static str>,
72    /// The staged artifacts whose `murmur.lock` pin a running capsule fetched. Set by
73    /// [`Self::set_runtime_artifacts`] before `session_start` is written; empty until then.
74    runtime_artifacts: Vec<RuntimeArtifact>,
75    /// Where staging took the facts about each allowlisted binary it examined. Set by
76    /// [`Self::set_host_facts`] before `session_start` is written; empty until then.
77    host_facts: Vec<HostFactsRecord>,
78    /// What staging read for `inference.instruction_files`, one record per entry. Set by
79    /// [`Self::set_instruction_files`] before `session_start` is written; `None` until then and
80    /// for a capsule without the key.
81    instruction_files: Option<Vec<crate::instruction_files::InstructionFileRecord>>,
82    /// The session `mur run --resume` continued, verbatim as the operator's address resolved it.
83    /// `None` on every ordinary launch. Written to `session_start` on both, so its absence
84    /// identifies a trace from a runtime that predates the key.
85    resumed_from: Option<String>,
86    /// The launch-scoped context id: the `--context` value, or the id `--resume` resolved to.
87    /// `None` when each task of this launch mints its own.
88    context_id: Option<String>,
89    /// The session that spawned this one, and the delegation that created it, read out of the
90    /// handle the spawning capsule's launcher injected. Both `None` for a capsule nobody
91    /// delegated, and both omitted from `session_start` when absent rather than written as null.
92    spawned_by: Option<String>,
93    spawn_delegation_id: Option<String>,
94    /// The formation this session is a member of. Set by [`Self::set_formation_id`] before
95    /// `session_start` is written; `None` for a session in no formation.
96    formation_id: Option<FormationId>,
97    /// This session's roster name and callees, set by [`Self::set_formation_member`] for a
98    /// formation member.
99    formation_member: Option<(String, Vec<String>)>,
100    session_start_time: Instant,
101    /// The session node of the event tree: the `event_id` `session_start` carries, and the
102    /// `parent_id` every launch-scoped event names. Minted in [`TraceWriter::open`] rather than
103    /// at write time so [`ResourceTraceAppender`], which is opened before the frame is written,
104    /// can be handed the same value.
105    session_event_id: String,
106    session_started: bool,
107    session_ended: bool,
108    /// The task node: the `event_id` of the active task's `task_start`. Set by
109    /// [`Self::write_task_start`], cleared by [`Self::write_task_end`], `None` between tasks.
110    task_event_id: Option<String>,
111    /// The turn node: the `event_id` of the active turn's own agent-loop `inference`. Every
112    /// event a turn produces hangs off it. Cleared at both task boundaries so a new task never
113    /// parents its first events to the previous task's last turn.
114    turn_event_id: Option<String>,
115    // Running totals — updated as events are written, used for fallback session_end on error exit
116    total_turns: u32,
117    total_input_tokens: u64,
118    total_output_tokens: u64,
119    total_tool_calls: u32,
120    total_shell_calls: u32,
121    // Per-task counters — reset on each write_task_start; consumed by write_task_end
122    task_turns: u32,
123    task_input_tokens: u64,
124    task_output_tokens: u64,
125    task_tool_calls: u32,
126    task_shell_calls: u32,
127    /// Agent-loop `inference` lines (`origin` absent) written since `task_start`, counted or
128    /// not; a hook's `run-inference` record is not one. Where an attempt's turn numbering starts,
129    /// so a task's turns number straight through its reopens and continuations. Reset at both
130    /// task boundaries, so a run outside any task frame starts at 0.
131    task_agent_turns: u32,
132    /// Counted hook `run-inference` lines (`origin` present) written since `task_start`: the part
133    /// of [`Self::task_turns`] no agent-loop turn spent. Reset with the other per-task counters.
134    task_hook_inferences: u32,
135    /// Agent-loop `inference` lines whose `stop_reason` is `"max_tokens"`, written since
136    /// `task_start`: `task_end.capped_turns`. Reset with the other per-task counters.
137    task_capped_turns: u32,
138    task_start_instant: Option<Instant>,
139    pub(crate) active_task_id: Option<String>,
140    /// How many `task_failed` lines this writer has written, so a caller can tell whether one run
141    /// wrote its own without comparing reasons.
142    task_failures_written: u64,
143    /// The `reason` of the last `task_failed` line, as written (already capped).
144    last_task_failure: Option<String>,
145    /// The spend ceilings `session_start` records. Set by [`Self::set_spend_ceilings`].
146    max_session_tokens: Option<u64>,
147    machine_tokens_per_day: Option<u64>,
148    /// What `session-status` reads, fed by this writer's `inference`, `task_start` and `task_end`
149    /// writes and named by `session_start.deadline`. Set by [`Self::set_session_status`]; `None`
150    /// feeds nothing.
151    session_status: Option<std::sync::Arc<crate::session_status::SessionStatus>>,
152    /// The session's record of component calls the wall-clock bound stopped, drained into
153    /// `call_deadline` lines ahead of every line this writer writes. Set by
154    /// [`Self::set_call_deadlines`]; `None` drains nothing.
155    call_deadlines: Option<crate::limits::CallDeadlines>,
156}
157
158/// Provenance of an inference record that did **not** come from the agent
159/// loop's own turn — today, only a hook's `run-inference` call. `None` at a
160/// `write_inference`/`emit_inference` call site means "ordinary agent-loop
161/// turn", which serializes exactly as it did before this existed.
162#[derive(Debug, Clone)]
163pub(crate) struct InferenceOrigin {
164    /// `hook:<manifest name>` of the hook that made the call.
165    pub(crate) source: String,
166    /// Model string actually sent (the attempted model, on failure).
167    pub(crate) model: String,
168}
169
170/// How one `transport: http` call was sent, as its `inference` line records it.
171#[derive(Debug, Clone, PartialEq)]
172pub(crate) struct InferenceCallSettings {
173    /// The output cap the request carried as `max_tokens`, and the amount the spend meter
174    /// reserved for it.
175    pub(crate) max_output_tokens: u32,
176    /// The input budget the call was measured against, `min(input ceiling, context window −
177    /// max_output_tokens)`; `None` when its model has no window.
178    pub(crate) input_budget: Option<u32>,
179    /// The `MURMUR_INFERENCE_DRIVER_CONFIG` the call's driver ran with, from
180    /// [`recorded_driver_config`]. `None` when the driver saw none.
181    pub(crate) driver_config: Option<Value>,
182}
183
184/// The `MURMUR_INFERENCE_DRIVER_CONFIG` in `env`, as an `inference` line records it: `None` when
185/// `env` has none, otherwise its JSON with every object key and string leaf passed through
186/// `redact`, at any depth. A number leaf whose decimal text `redact` changes becomes the string
187/// [`crate::gateway_credential::REDACTED`]; booleans and nulls are kept as they are.
188///
189/// A value that is not JSON is recorded as `redact(value)`, a JSON string: it is what the driver
190/// saw. The manifest parser only produces JSON objects here.
191pub(crate) fn recorded_driver_config(
192    env: &[(String, String)],
193    redact: impl Fn(&str) -> String,
194) -> Option<Value> {
195    fn redact_value(value: Value, redact: &impl Fn(&str) -> String) -> Value {
196        match value {
197            Value::String(text) => Value::String(redact(&text)),
198            Value::Number(number) => {
199                let text = number.to_string();
200                if redact(&text) == text {
201                    Value::Number(number)
202                } else {
203                    Value::String(crate::gateway_credential::REDACTED.to_string())
204                }
205            }
206            Value::Array(items) => Value::Array(
207                items
208                    .into_iter()
209                    .map(|item| redact_value(item, redact))
210                    .collect(),
211            ),
212            Value::Object(entries) => Value::Object(
213                entries
214                    .into_iter()
215                    .map(|(key, item)| (redact(&key), redact_value(item, redact)))
216                    .collect(),
217            ),
218            other @ (Value::Bool(_) | Value::Null) => other,
219        }
220    }
221    let (_, raw) = env
222        .iter()
223        .find(|(key, _)| key == "MURMUR_INFERENCE_DRIVER_CONFIG")?;
224    Some(match serde_json::from_str::<Value>(raw) {
225        Ok(value) => redact_value(value, &redact),
226        Err(_) => Value::String(redact(raw)),
227    })
228}
229
230/// `compaction_declined.reason` when the threshold was crossed but no bound hook returned
231/// `replace-context`.
232pub(crate) const COMPACTION_DECLINED_NO_HOOK_REPLACEMENT: &str = "no_hook_replacement";
233
234/// `compaction_declined.reason` when a hook's replacement was rejected because its tool calls
235/// and tool results did not pair up.
236pub(crate) const COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL: &str = "unresolved_tool_call";
237
238/// `context_seed.outcome` when the whole proposal fit the budget and was committed as-is.
239pub(crate) const SEED_OUTCOME_SEEDED: &str = "seeded";
240
241/// `context_seed.outcome` when the proposal was over budget and the oldest messages were
242/// dropped from its front until the rest fit.
243pub(crate) const SEED_OUTCOME_TRIMMED: &str = "trimmed";
244
245/// `context_seed.outcome` when the overflowing front was handed to the compaction hook and
246/// its summary became the seed's first message.
247pub(crate) const SEED_OUTCOME_COMPACTED: &str = "compacted";
248
249/// `context_seed.outcome` when nothing was committed. Always paired with a `reason`.
250pub(crate) const SEED_OUTCOME_REJECTED: &str = "rejected";
251
252/// `context_seed.reason` when one message alone is wider than the whole budget. Trimming
253/// cannot help — dropping everything else still leaves it over — so nothing is committed.
254pub(crate) const SEED_REJECTED_MESSAGE_OVER_BUDGET: &str = "message_over_budget";
255
256/// `context_seed.reason` when the proposal overflows the budget by more multiples of it than
257/// the runtime will summarize. A seed that far over is a broken hook, not a full memory.
258pub(crate) const SEED_REJECTED_OVERFLOW_OVER_LIMIT: &str = "overflow_over_limit";
259
260/// `context_seed.reason` when the capsule declares no `context.max_tokens`, so
261/// `context.seed_budget` is a fraction of nothing and there is no ceiling to enforce.
262pub(crate) const SEED_REJECTED_NO_BUDGET: &str = "no_budget";
263
264/// `context_seed.reason` when the session runs `inference.transport: process`, which owns its
265/// own context and has no host-side message list to seed.
266pub(crate) const SEED_REJECTED_UNSUPPORTED_TRANSPORT: &str = "unsupported_transport";
267
268/// `retention.store` for the session directories under a workdir.
269pub(crate) const RETENTION_STORE_SESSIONS: &str = "sessions";
270
271/// `retention.store` for the conversation records under `~/.murmur/conversations/`.
272pub(crate) const RETENTION_STORE_RECORDS: &str = "records";
273
274/// `retention.reason` when `trace.retain.max_sessions` put a session outside the newest N.
275pub(crate) const RETENTION_REASON_MAX_SESSIONS: &str = "max_sessions";
276
277/// `retention.reason` when `trace.retain.max_age` or `context.retain.max_age` put a session or a
278/// record outside the window.
279pub(crate) const RETENTION_REASON_MAX_AGE: &str = "max_age";
280
281/// `retention.reason` when `context.retain.max_messages` truncated the front of a record.
282pub(crate) const RETENTION_REASON_MAX_MESSAGES: &str = "max_messages";
283
284/// Mint one event identity: `evt_` + a UUID v7 in simple form, the same scheme `ses_`, `tsk_`,
285/// `ctx_`, `dep_` and `req_` use. Ids therefore sort by mint time and carry their own
286/// millisecond timestamp, so a trace can be ordered and time-bounded without reading any
287/// payload field.
288///
289/// Called once per line at the moment of the write. An id is never reused, never derived from
290/// the event's content, and never reconstructed from anything else in the file.
291pub(crate) fn new_event_id() -> String {
292    format!("evt_{}", uuid::Uuid::now_v7().simple())
293}
294
295// ── Event structs (Serialize → JSONL lines) ──────────────────────────────────
296//
297// Every struct opens with `event_type`, `event_id`, `parent_id`, `session_id` and `timestamp`,
298// in that order, so a human scanning the file reads a line's identity before its payload.
299// `parent_id` is the `event_id` of the event this one hangs off — see [`TraceWriter`]'s
300// `session_parent`/`task_parent`/`turn_parent` for which node each event names, and
301// [`new_event_id`] for the id format.
302//
303// The turn-level events (`inference`, `tool_call`, `skill_call`, `shell`, `compaction`,
304// `compaction_declined`) additionally carry `task_id`, taken from the writer's active task and
305// always written — `null` when no task is in scope. `task_start`/`task_end` carry their own and
306// are not duplicated.
307
308#[derive(Serialize)]
309struct SessionStartEvent {
310    event_type: &'static str,
311    event_id: String,
312    parent_id: Option<String>,
313    session_id: String,
314    timestamp: u64,
315    capsule_name: String,
316    capsule_version: String,
317    model: String,
318    /// `inference.max_turns`. Always written, `null` when no cap applies.
319    max_turns: Option<u32>,
320    /// The primary driver choice's output cap and context window in force, and where each came
321    /// from. Always written, `null` under `transport: process`, which resolves none.
322    model_limits: Option<crate::model_limits::ResolvedModelLimits>,
323    /// `inference.max_session_tokens` as this session enforces it. Always written, `null` when
324    /// no session ceiling applies.
325    max_session_tokens: Option<u64>,
326    /// `spend.machine_tokens_per_day` when this session keeps the machine ledger. Always written,
327    /// `null` when the machine ceiling does not cover this session.
328    machine_tokens_per_day: Option<u64>,
329    /// The deadline this session runs under: the earliest of its own `mur run --deadline` and the
330    /// one it inherited. Always written, `null` when it has neither.
331    deadline: Option<SessionStartDeadline>,
332    capabilities: Vec<String>,
333    tools_declared: Vec<String>,
334    /// Strongest containment class any source asked for. A *requirement*, not an observation.
335    containment_declared: ContainmentClass,
336    /// What this session's commands actually ran under: the class of the tier it installed (the
337    /// probed host tier capped by the declared floor), capped at `advisory` by `workdir_exec` below
338    /// or a runtime directory inside the workdir. Reading these two together is how a session's
339    /// trace shows whether the capsule ran under the containment its operator asked for.
340    containment_achieved: ContainmentClass,
341    /// `capabilities.filesystem.workdir_exec`. Always written, including `false`, so its absence
342    /// identifies a trace from a runtime that predates the key rather than a capsule that declined
343    /// it.
344    ///
345    /// `true` is the reason a trace can show `containment_achieved: advisory` on a host whose
346    /// `session_start` would otherwise say `scoped` — and it is the record that this session's
347    /// `capabilities.shell.allow` was advisory too, since anything in the workdir could run.
348    workdir_exec: bool,
349    /// Where this host's permission to create an unprivileged user namespace came from, as
350    /// `UsernsGrant::wire_name` — `null` only off Linux, where AppArmor does not exist.
351    ///
352    /// Mirrored to the top level from `effective_grants` for the same reason `workdir_exec` is:
353    /// an auditor should not have to descend into a nested object to answer it. Recorded because
354    /// `containment_achieved: sealed` reached through the shipped AppArmor profile and the same
355    /// class reached on a host whose unprivileged-userns hardening is switched off for every
356    /// binary are two very different records, and this key is the only thing separating them.
357    ///
358    /// Always written, on the same terms as `workdir_exec`: its absence identifies a trace from a
359    /// runtime that predates the key.
360    userns_grant: Option<crate::sealed::UsernsGrant>,
361    /// The complete grant set this session ran under — every destination, binary, path and
362    /// environment variable the policy actually opened, plus the enforcement tier installed — in the
363    /// exact shape `mur run --explain-scope --json` prints for the same policy on the same host.
364    ///
365    /// `capabilities` above stays what it always was: a list of category *names*. It answers "did
366    /// this session have network access?" but not "to where?", which is the question an auditor
367    /// reading a finished trace actually has. This field answers it without re-parsing the
368    /// manifest, which by then may have changed or moved.
369    effective_grants: ScopeReport,
370    /// Where the system prompt this session ran with came from: `"manifest"` when the manifest's
371    /// own `inference.system_prompt`/`system_prompt_file`/`system_prompt_artifact` supplied it,
372    /// `"cli"` when `mur run --system-prompt` overrode it (including when the override was empty
373    /// and therefore cleared it), `"none"` when no prompt was in effect at all.
374    ///
375    /// Always written, on the same terms as `workdir_exec`: its absence identifies a trace from a
376    /// runtime that predates the key, not a session that had no prompt.
377    system_prompt_source: &'static str,
378    /// SHA-256 (lowercase hex) of the resolved prompt — the text as `resolve_system_prompt`
379    /// returned it, before the `[Capsule]` identity block is prepended — or `null` when no prompt
380    /// was in effect. Always written, so two sessions can be compared for prompt equality without
381    /// either trace having to carry the prompt itself.
382    ///
383    /// Deliberately not the same value as `inference.system_sha`, which covers the augmented
384    /// prompt that actually went on the wire: this one names the prompt the operator wrote.
385    /// Under `trace.capture: content` those resolved bytes are also the blob this hash names.
386    system_prompt_sha256: Option<String>,
387    /// Where the inference credential this session attaches came from: `"config"`,
388    /// `"environment"`, `"manifest"` or `"none"`. Never the value.
389    credential_source: &'static str,
390    /// Every credential gateway the session holds, the inference gateway first. Always written,
391    /// and empty for a session with none, so an auditor can tell an unmetered third-party
392    /// upstream from spend the ceilings cover.
393    gateways: Vec<SessionGateway>,
394    /// What a controller holding this session's control token may change: the setting names and
395    /// secret names `control:` declares. Omitted entirely for a capsule with no `control:` block.
396    #[serde(skip_serializing_if = "Option::is_none")]
397    control: Option<SessionControl>,
398    /// Every driver choice the agent loop may be switched to, the primary first, each with its
399    /// credential's source and whether it resolved at launch. Omitted entirely for a capsule that
400    /// declares no `inference.alternates`.
401    #[serde(skip_serializing_if = "Vec::is_empty")]
402    inference_choices: Vec<SessionInferenceChoice>,
403    /// `inference.tool_refresh`: `"compaction"` or `"immediate"` under `transport: http`, `null`
404    /// under `transport: process`, where the harness owns its tool list. Always written.
405    tool_refresh: Option<&'static str>,
406    /// Every staged artifact whose `murmur.lock` origin is `runtime` — pinned by a running
407    /// capsule's `manage.pull()`, never vetted by the operator — in staging order. Each reaches
408    /// the model marked untrusted. Always written, `[]` when there are none.
409    runtime_artifacts: Vec<RuntimeArtifact>,
410    /// One object per allowlisted binary staging examined, saying whether its facts came from a
411    /// kept `~/.murmur/host_facts` entry or were worked out, and why. Always written, `[]` when
412    /// no binary was examined.
413    host_facts: Vec<HostFactsRecord>,
414    /// One object per `inference.instruction_files` entry, in manifest order: its `path` as
415    /// written and its `status`, with `resolved`, `bytes`, `included_bytes`, `truncated` and
416    /// `sha256` (of the included text) on a `read` entry, `same_as` on a `same_file` one and
417    /// `error` on an `unreadable` one. `null` for a capsule without the key, `[]` for an empty
418    /// list. Always written.
419    instruction_files: Option<Vec<crate::instruction_files::InstructionFileRecord>>,
420    /// The session `mur run --resume` continued, or `null` on an ordinary launch. Together with
421    /// `context_id` below it is what makes a resumed conversation followable back through the
422    /// sessions that built it.
423    ///
424    /// Always written, on the same terms as `workdir_exec`: its absence identifies a trace from a
425    /// runtime that predates the key.
426    resumed_from: Option<String>,
427    /// The context id every task of this launch runs under — the `mur run --context` value, or
428    /// the id `--resume` resolved to — and `null` when each task mints its own. `task_start`
429    /// carries the id a task actually ran under either way.
430    ///
431    /// Always written, on the same terms as `resumed_from`.
432    context_id: Option<String>,
433    /// The `ses_` id of the session that spawned this one, for a capsule launched by another
434    /// capsule's `delegate-task`. Omitted from the record entirely for every other launch: the
435    /// field is absent, not null.
436    #[serde(skip_serializing_if = "Option::is_none")]
437    spawned_by: Option<String>,
438    /// The `dlg_` id of the delegation that created this session, character-identical to the id
439    /// in the spawning session's own `delegation_start`. Present exactly when `spawned_by` is.
440    #[serde(skip_serializing_if = "Option::is_none")]
441    delegation_id: Option<String>,
442    /// The formation this session is a member of. Omitted entirely for a session in no formation,
443    /// absent rather than null, on the same terms as `spawned_by`. Being on `session_start`, it is
444    /// on the trace's first line, which is the only line a reader grouping sessions reads.
445    #[serde(skip_serializing_if = "Option::is_none")]
446    formation_id: Option<FormationId>,
447    /// This session's roster name, for a session its formation launcher handed credentials to.
448    /// Omitted for every other session, a delegated child of a member included.
449    #[serde(skip_serializing_if = "Option::is_none")]
450    formation_member: Option<String>,
451    /// The members this one may call, in roster order: `[]` for a member that may call nobody.
452    /// Present exactly when `formation_member` is.
453    #[serde(skip_serializing_if = "Option::is_none")]
454    formation_callees: Option<Vec<String>>,
455}
456
457/// `session_start.deadline`.
458#[derive(Serialize)]
459struct SessionStartDeadline {
460    /// When the runtime ends the session, RFC 3339 UTC.
461    at: String,
462    /// Seconds from `mur run`'s start to `at`: the `--deadline` value, or for an inherited
463    /// deadline the whole seconds left at the start, rounded up.
464    secs: u64,
465    /// The notice margin in force, `lifecycle.deadline_notice_secs` or its default; `0` sends
466    /// none.
467    notice_secs: u64,
468    /// `SessionDeadline::source`: `"own"` or `"inherited"`.
469    source: &'static str,
470}
471
472#[derive(Serialize)]
473struct InferenceEvent {
474    event_type: &'static str,
475    event_id: String,
476    parent_id: Option<String>,
477    session_id: String,
478    timestamp: u64,
479    turn: u32,
480    task_id: Option<String>,
481    /// The input this turn cost, and the number the task and session totals accumulate.
482    ///
483    /// Under `transport: http` it is the runtime's own tiktoken estimate of the request, counted
484    /// before the request was sent, and the provider's own count sits beside it in
485    /// `input_tokens_actual`. Under `transport: process` the runtime estimates nothing — the
486    /// harness holds the conversation — so this *is* the harness's own reported count and
487    /// `input_tokens_actual` is absent: there is one measurement on that transport and it is
488    /// recorded once.
489    ///
490    /// Absent means no count was made at all: a `process` driver reporting no usage, or a failed
491    /// call that is not counted (see [`TraceWriter::write_failed_inference`]). Never conflate it
492    /// with `0`, which is a count of none.
493    #[serde(skip_serializing_if = "Option::is_none")]
494    input_tokens: Option<u64>,
495    /// The output this turn cost, on the same terms as [`Self::input_tokens`]: the runtime's own
496    /// tiktoken estimate of the raw driver response under `http`, the harness's own reported
497    /// count under `process`.
498    #[serde(skip_serializing_if = "Option::is_none")]
499    output_tokens: Option<u64>,
500    decision: String,
501    /// The provider's own stop reason for this turn, verbatim as the agent loop dispatched on
502    /// it. Present on every agent-loop turn, including one whose driver reported nothing — an
503    /// empty string then, because that is what the loop dispatched on. `"error"` on every failed
504    /// call, on either transport and for a hook's `run-inference`. Otherwise absent means no
505    /// driver response was parsed at all: a hook's `run-inference` and the `process` transport
506    /// both name their `decision` locally and never saw a provider stop reason.
507    #[serde(skip_serializing_if = "Option::is_none")]
508    stop_reason: Option<String>,
509    tool_name: Option<String>,
510    /// The provider's own counts for this call, each written only when the driver reported
511    /// it. Absent means the driver reported nothing, never zero.
512    #[serde(skip_serializing_if = "Option::is_none")]
513    input_tokens_actual: Option<u64>,
514    #[serde(skip_serializing_if = "Option::is_none")]
515    output_tokens_actual: Option<u64>,
516    #[serde(skip_serializing_if = "Option::is_none")]
517    cached_tokens: Option<u64>,
518    #[serde(skip_serializing_if = "Option::is_none")]
519    cache_write_tokens: Option<u64>,
520    /// The part of the output the provider or harness reports as reasoning — a subset of
521    /// `output_tokens`, never an addition to it. Absent when nothing reported one.
522    #[serde(skip_serializing_if = "Option::is_none")]
523    thinking_tokens: Option<u64>,
524    /// Where this inference came from. Absent for an ordinary agent-loop turn,
525    /// so every pre-existing consumer sees a byte-identical record; `"hook:<name>"`
526    /// for a completion that hook ran through `murmur:runtime/inference`'s
527    /// `run-inference`.
528    #[serde(skip_serializing_if = "Option::is_none")]
529    origin: Option<String>,
530    /// The driver choice that served an agent-loop turn, by name. Written only for a capsule that
531    /// declares `inference.alternates`, beside `model`.
532    #[serde(skip_serializing_if = "Option::is_none")]
533    driver_choice: Option<String>,
534    /// Model string actually sent for this call (the attempted model, on failure). Written
535    /// alongside `origin` for a hook's completion, and alongside `driver_choice` for an agent-loop
536    /// turn of a capsule that declares alternates. Any other turn's model is the session-start
537    /// record's and is not repeated per turn.
538    #[serde(skip_serializing_if = "Option::is_none")]
539    model: Option<String>,
540    /// The output cap this request carried as `max_tokens`, which is also what the spend meter
541    /// reserved for it. Written on every `transport: http` line — agent-loop turn, hook
542    /// `run-inference` and failed call alike — and on no `transport: process` line, where the
543    /// harness runs the model and the runtime sets no cap.
544    #[serde(skip_serializing_if = "Option::is_none")]
545    max_output_tokens: Option<u32>,
546    /// The call's input budget, `min(input ceiling, context window − max_output_tokens)`.
547    /// Present exactly when `max_output_tokens` is; `null` there means the model has no window.
548    /// The outer `Option` is the key, the inner one its value.
549    #[serde(skip_serializing_if = "Option::is_none")]
550    input_budget: Option<Option<u32>>,
551    /// The `MURMUR_INFERENCE_DRIVER_CONFIG` this call's driver ran with, every credential value
552    /// the session holds replaced with `[redacted]`. Present exactly when `max_output_tokens` is;
553    /// `null` there means the driver saw no config. The outer `Option` is the key, the inner one
554    /// its value.
555    #[serde(skip_serializing_if = "Option::is_none")]
556    driver_config: Option<Option<Value>>,
557    /// Identities of the messages this request embedded, in the order they sat in it. Under an
558    /// active driver continuation only the tail past the acked length is sent, and this names
559    /// exactly that tail. Empty — and therefore absent — for a hook's own `run-inference` and for
560    /// the `process` transport, neither of which sends a message list the runtime minted.
561    #[serde(skip_serializing_if = "Vec::is_empty")]
562    message_ids: Vec<String>,
563    /// SHA-256 (lowercase hex) of the UTF-8 bytes of this request's `system` string — the
564    /// augmented prompt with the `[Capsule]` block already in it.
565    ///
566    /// These are the bytes Murmur **sent**, not what the model **saw**: provider-side injection,
567    /// tokenizer differences and safety layers all happen past the wire and are invisible to the
568    /// runtime. Absent under `trace.capture: none`, and for a record the runtime did not build
569    /// the payload for — a hook's `run-inference`, and the `process` transport.
570    #[serde(skip_serializing_if = "Option::is_none")]
571    system_sha: Option<String>,
572    /// SHA-256 (lowercase hex) of this request's serialized `tools` array. The bytes Murmur
573    /// sent, on the same terms as [`Self::system_sha`].
574    #[serde(skip_serializing_if = "Option::is_none")]
575    tools_sha: Option<String>,
576    /// SHA-256 (lowercase hex) of the raw driver response body, as the loop read it before
577    /// parsing. What the driver returned to Murmur, byte for byte.
578    #[serde(skip_serializing_if = "Option::is_none")]
579    response_sha: Option<String>,
580    /// SHA-256 (lowercase hex) of each message this request embedded, in send order — one entry
581    /// per `message_ids` entry, over the same messages after the runtime's identity keys are
582    /// stripped. The bytes Murmur sent, on the same terms as [`Self::system_sha`].
583    ///
584    /// Not redundant with `message_ids`: an id names an entity and is freshly minted every run,
585    /// so comparing two runs' id arrays only says every id differs. Comparing these says where
586    /// the two prompts stopped agreeing — the divergence index is the first unequal position.
587    #[serde(skip_serializing_if = "Vec::is_empty")]
588    message_shas: Vec<String>,
589    /// Why the call failed, from the `INFERENCE_ERROR_*` vocabulary. Present exactly on a record
590    /// whose `decision` and `stop_reason` are both `"error"`; absent on every call that returned
591    /// an answer, however unusable the loop then found it.
592    #[serde(skip_serializing_if = "Option::is_none")]
593    error_code: Option<String>,
594    /// The failure in words, present exactly when `error_code` is. Every credential value the
595    /// session holds is already replaced with `[redacted]`, and the text is capped at
596    /// [`MAX_TASK_FAILURE_REASON_BYTES`] on a character boundary.
597    #[serde(skip_serializing_if = "Option::is_none")]
598    error: Option<String>,
599    /// The HTTP status the runtime's credential gateway received for the failed call. Absent when
600    /// no request reached the provider, on the `process` transport, and for a driver whose
601    /// gateway the runtime does not own.
602    #[serde(skip_serializing_if = "Option::is_none")]
603    provider_status: Option<u16>,
604    /// Milliseconds from the start of the attempt this record describes until the driver's
605    /// request reached the runtime's credential gateway. Present exactly on a call made through
606    /// a runtime-owned gateway; `null` there means no request reached it. The outer `Option` is
607    /// the key, the inner one its value.
608    #[serde(skip_serializing_if = "Option::is_none")]
609    gateway_reached_ms: Option<Option<u64>>,
610    /// Milliseconds from the start of the attempt until the provider's response head arrived at
611    /// the gateway, the last one when the gateway resent. Present exactly when
612    /// `gateway_reached_ms` is; `null` there means no response head arrived.
613    #[serde(skip_serializing_if = "Option::is_none")]
614    upstream_responded_ms: Option<Option<u64>>,
615    /// How many attempts the call took, written only when it took more than one. Each retry
616    /// before the last attempt has its own `inference_retry` line.
617    #[serde(skip_serializing_if = "Option::is_none")]
618    attempts: Option<u32>,
619    /// Why a call whose last attempt failed transiently was not made again:
620    /// [`crate::inference_retry::RetryStop::wire_name`]. Absent on every other record.
621    #[serde(skip_serializing_if = "Option::is_none")]
622    retry_stopped: Option<&'static str>,
623}
624
625/// The bodies one driver request put on the wire, split into the pieces an `inference` event
626/// hashes.
627///
628/// Built from the payload [`crate::agent::build_driver_payload`] produced and the raw response
629/// string the driver returned, so what the trace names and what the driver received cannot
630/// drift: there is no second serialization for them to drift apart in.
631///
632/// Every piece is the bytes Murmur **sent** (or, for the response, was handed back), never what
633/// the model saw.
634pub(crate) struct WireCapture {
635    /// The payload's `system` string value as UTF-8 text — the augmented prompt, `[Capsule]`
636    /// block included. Stored as text so its blob `cat`s as a readable prompt.
637    system: Vec<u8>,
638    /// The payload's `tools` array, serialized.
639    tools: Vec<u8>,
640    /// Each element of the payload's `messages` array, serialized, in send order. That array is
641    /// the `wire_messages` slice after `strip_message_identity`, so no message identity key can
642    /// reach a hash or a blob.
643    messages: Vec<Vec<u8>>,
644    /// The raw driver response body, exactly as the loop read it.
645    response: Vec<u8>,
646}
647
648impl WireCapture {
649    /// Split a built driver payload and its raw response into the four bodies the trace hashes.
650    ///
651    /// `payload` must be the value that was serialized and handed to the driver; taking the
652    /// pieces out of it, rather than rebuilding them from the loop's own state, is what makes a
653    /// blob byte-identical to the substring of the request it came from.
654    pub(crate) fn from_driver_payload(payload: &Value, response: &str) -> Self {
655        Self {
656            system: payload
657                .get("system")
658                .and_then(Value::as_str)
659                .unwrap_or_default()
660                .as_bytes()
661                .to_vec(),
662            tools: payload
663                .get("tools")
664                .map(|tools| serde_json::to_vec(tools).unwrap_or_default())
665                .unwrap_or_default(),
666            messages: payload
667                .get("messages")
668                .and_then(Value::as_array)
669                .map(|messages| {
670                    messages
671                        .iter()
672                        .map(|message| serde_json::to_vec(message).unwrap_or_default())
673                        .collect()
674                })
675                .unwrap_or_default(),
676            response: response.as_bytes().to_vec(),
677        }
678    }
679}
680
681#[derive(Serialize)]
682struct ToolCallEvent {
683    event_type: &'static str,
684    event_id: String,
685    parent_id: Option<String>,
686    session_id: String,
687    timestamp: u64,
688    turn: u32,
689    task_id: Option<String>,
690    tool_name: String,
691    /// The provider's own id for this call, as it appeared on the driver response block that
692    /// asked for it. Recorded verbatim and never parsed — the same contract `resource_id` has.
693    /// `null` when the provider named none, which is how every host-synthesized call reads.
694    tool_call_id: Option<String>,
695    input: Value,
696    input_bytes: u64,
697    #[serde(skip_serializing_if = "Option::is_none")]
698    output: Option<String>,
699    output_bytes: u64,
700    duration_ms: u64,
701    status: String,
702    /// The runtime's own text when it could not run the call, such as a native tool or shell
703    /// command whose sandbox setup failed before exec. Written under every [`TraceCapture`],
704    /// unlike `output`; peer handle tokens are redacted and the text is capped at
705    /// [`MAX_TASK_FAILURE_REASON_BYTES`] on a character boundary. Absent when the tool ran,
706    /// including when it reported its own failure.
707    #[serde(skip_serializing_if = "Option::is_none")]
708    error: Option<String>,
709    /// The tool's self-declared `state_effect` for this call (`read`/`mutate`),
710    /// lifted verbatim from `tool-result.metadata`. Absent when the tool declared
711    /// nothing; consumers treat absence conservatively. See `wit/tool.wit`.
712    #[serde(skip_serializing_if = "Option::is_none")]
713    state_effect: Option<String>,
714    /// The resource this call addressed, as declared by the tool and lifted verbatim
715    /// from `tool-result.metadata`. An opaque, tool-defined string — never parsed here.
716    /// Absent when the tool declared nothing, in which case consumers fall back to
717    /// guessing the resource from the call's input. See `wit/tool.wit`.
718    #[serde(skip_serializing_if = "Option::is_none")]
719    resource_id: Option<String>,
720    /// The images the call returned, accepted or refused, in the order it named them. Absent
721    /// when it returned none. Never holds image bytes.
722    #[serde(skip_serializing_if = "Vec::is_empty")]
723    attachments: Vec<AttachmentRecord>,
724}
725
726/// One image a tool call returned or a task message carried, as the trace records it.
727///
728/// An accepted image carries `bytes` and `sha256`, the name of its blob beside the conversation;
729/// a refused one carries `refused`, the refusal code its note ends with. `path` is the work-folder
730/// path a tool named, or `message.parts[<i>]` for an image an A2A message carried.
731#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
732pub(crate) struct AttachmentRecord {
733    pub(crate) path: String,
734    pub(crate) media_type: String,
735    #[serde(skip_serializing_if = "Option::is_none")]
736    pub(crate) bytes: Option<u64>,
737    #[serde(skip_serializing_if = "Option::is_none")]
738    pub(crate) sha256: Option<String>,
739    #[serde(skip_serializing_if = "Option::is_none")]
740    pub(crate) refused: Option<&'static str>,
741}
742
743#[derive(Serialize)]
744struct SkillCallEvent {
745    event_type: &'static str,
746    event_id: String,
747    parent_id: Option<String>,
748    session_id: String,
749    timestamp: u64,
750    turn: u32,
751    task_id: Option<String>,
752    skill_name: String,
753    output_bytes: u64,
754    duration_ms: u64,
755    status: String,
756    /// The skill's `murmur.lock` origin: `"operator"` or `"runtime"`. Always written.
757    origin: &'static str,
758    /// `"trusted"` for an operator pin, `"untrusted"` for a runtime one, whose guidance reached
759    /// the model fenced. Always written, in the spelling `task_start.trust` uses.
760    trust: &'static str,
761}
762
763#[derive(Serialize)]
764struct ShellEvent {
765    event_type: &'static str,
766    event_id: String,
767    parent_id: Option<String>,
768    session_id: String,
769    timestamp: u64,
770    turn: u32,
771    task_id: Option<String>,
772    /// The program that ran — a canonical absolute path when the host `PATH` resolved
773    /// the invoked name, else the bare name. `command` carries the argument list alone,
774    /// so this is the only field that says *what* ran.
775    binary: String,
776    command: String,
777    exit_code: i32,
778    stdout_bytes: u64,
779    stderr_bytes: u64,
780    duration_ms: u64,
781    /// The `capabilities.resources` field this subprocess was killed for exceeding, when the
782    /// kernel's own evidence names exactly one (`SIGXCPU`/`SIGXFSZ`, or a cgroup
783    /// `memory.events`/`pids.events` counter that moved). Omitted from the JSONL entirely
784    /// otherwise — including for a subprocess that died for a reason no single limit can be
785    /// pinned to, which must not read as "no limit was involved".
786    #[serde(skip_serializing_if = "Option::is_none")]
787    resource_limit: Option<String>,
788}
789
790/// A shell command that outran its grace period and moved to the background. Hangs off the turn
791/// that started it, so `mur trace steps` shows it where the model saw it.
792#[derive(Serialize)]
793struct ShellDetachedEvent {
794    event_type: &'static str,
795    event_id: String,
796    parent_id: Option<String>,
797    session_id: String,
798    timestamp: u64,
799    turn: u32,
800    task_id: Option<String>,
801    work_id: String,
802    binary: String,
803    command: String,
804    /// The `lifecycle.shell_grace_secs` in force, in milliseconds — what this command outran.
805    /// Absent when the session declares no grace, so the command was demoted by an abandoned
806    /// call or a cancel.
807    #[serde(skip_serializing_if = "Option::is_none")]
808    grace_ms: Option<u64>,
809}
810
811/// A detached command's completion reaching the agent, written once per work id by whichever path
812/// delivered it: [`ResourceTraceAppender::write_shell_completed`] right after the
813/// `background_command` line of the call that returned the finished state, or
814/// [`TraceWriter::write_shell_completed`] where the task loop queues it as a task. Hangs off the
815/// session rather than a turn: the turn that started the command may be long over.
816#[derive(Serialize)]
817struct ShellCompletedEvent {
818    event_type: &'static str,
819    event_id: String,
820    parent_id: Option<String>,
821    session_id: String,
822    timestamp: u64,
823    work_id: String,
824    binary: String,
825    command: String,
826    exit_code: i32,
827    duration_ms: u64,
828    /// Epoch milliseconds at which the runtime recorded the command's exit, which is never after
829    /// [`Self::timestamp`].
830    exited_at_ms: u64,
831    /// Absolute path of the file holding the command's full stdout and stderr,
832    /// `logs/<work_id>.log` in the session's records directory.
833    output_path: String,
834    output_bytes: u64,
835    /// Attributed the same way [`ShellEvent::resource_limit`] is, and omitted on the same terms.
836    #[serde(skip_serializing_if = "Option::is_none")]
837    resource_limit: Option<String>,
838    /// `"ok"` or `"error"` — the field that tells a failed background command from a successful
839    /// one without reading its prose.
840    status: String,
841    /// [`crate::detached::Delivery::as_str`]: `"background_command"` or `"task"`.
842    delivered_via: &'static str,
843    /// The `completion`-origin task this completion was enqueued as, which is what makes "which
844    /// command produced this task" answerable from `trace.jsonl` alone. Present exactly when
845    /// [`Self::delivered_via`] is `"task"`.
846    #[serde(skip_serializing_if = "Option::is_none")]
847    completion_task_id: Option<String>,
848}
849
850impl ShellCompletedEvent {
851    fn new(
852        session_id: String,
853        parent_id: Option<String>,
854        completion: &crate::detached::DetachedCompletion,
855        delivery: crate::detached::Delivery,
856        completion_task_id: Option<&str>,
857    ) -> Self {
858        Self {
859            event_type: "shell_completed",
860            event_id: new_event_id(),
861            parent_id,
862            session_id,
863            timestamp: timestamp_ms(),
864            work_id: completion.work_id.clone(),
865            binary: completion.binary.clone(),
866            command: completion.command.clone(),
867            exit_code: completion.exit_code,
868            duration_ms: completion.duration_ms,
869            exited_at_ms: completion.exited_at_ms,
870            output_path: completion.output_path.clone(),
871            output_bytes: completion.output_bytes,
872            resource_limit: completion.resource_limit.clone(),
873            status: completion.status().to_string(),
874            delivered_via: delivery.as_str(),
875            completion_task_id: completion_task_id.map(str::to_string),
876        }
877    }
878}
879
880/// One answered `background-command` call. Hangs off the session, as `session_status` does.
881#[derive(Serialize)]
882struct BackgroundCommandEvent<'a> {
883    event_type: &'static str,
884    event_id: String,
885    parent_id: Option<String>,
886    session_id: String,
887    timestamp: u64,
888    /// The task that made the call. Absent outside a task.
889    #[serde(skip_serializing_if = "Option::is_none")]
890    task_id: Option<String>,
891    work_id: &'a str,
892    /// `status`, `wait` or `stop`.
893    operation: &'static str,
894    /// `running`, `exited` or `stopped`, as the call returned it.
895    state: &'static str,
896    /// Present once the command has exited.
897    #[serde(skip_serializing_if = "Option::is_none")]
898    exit_code: Option<i32>,
899    /// How long a `wait` blocked. Absent for the other operations.
900    #[serde(skip_serializing_if = "Option::is_none")]
901    waited_ms: Option<u64>,
902    /// Bytes in the log's output section, as the call returned it.
903    output_bytes: u64,
904}
905
906/// A detached command the session ended without carrying its result back. No task will ever report
907/// it; this record and the operator report beside it are what keep the loss visible.
908///
909/// The command was either still running at the sweep or finished during teardown, and the second
910/// case knows more: [`Self::exit_code`], [`Self::output_path`] and [`Self::output_bytes`] are
911/// written together for it and omitted together for the first. A command still running therefore
912/// produces a line byte-identical in shape to one written before those fields existed — omitted,
913/// never `null`, because `null` would read as a known-absent exit code rather than an unknown one.
914#[derive(Serialize)]
915struct ShellAbandonedEvent {
916    event_type: &'static str,
917    event_id: String,
918    parent_id: Option<String>,
919    session_id: String,
920    timestamp: u64,
921    work_id: String,
922    binary: String,
923    command: String,
924    /// How long the command had been running when the session gave up on it. For a command that
925    /// finished during teardown, its full duration from spawn to exit.
926    running_ms: u64,
927    /// `128 + signal` for a signal kill, as on `shell_completed`. Absent for a command still
928    /// running.
929    #[serde(skip_serializing_if = "Option::is_none")]
930    exit_code: Option<i32>,
931    /// Absolute path of the file holding the command's full stdout and stderr,
932    /// `logs/<work_id>.log` in the session's records directory. Absent for a command still
933    /// running, for which that file was never written and never will be.
934    #[serde(skip_serializing_if = "Option::is_none")]
935    output_path: Option<String>,
936    /// Size of that file. Present exactly when [`Self::output_path`] is.
937    #[serde(skip_serializing_if = "Option::is_none")]
938    output_bytes: Option<u64>,
939}
940
941/// A demoted command a later launch found unaccounted for, appended to the `trace.jsonl` of the
942/// session that started it.
943///
944/// Carries no `exit_code`, no `status`, no `duration_ms`, no `output_path` and no `output_bytes`:
945/// none of them exists for a command whose runtime died, and a record that named any of them
946/// would be readable as a result. The event type is the whole discriminator against
947/// [`ShellCompletedEvent`].
948///
949/// Its presence is also what stops the same work id being reported by a second resume, so this
950/// line and the `shell_detached` it answers live in the same file and are pruned together by
951/// [`crate::retention::prune_sessions`].
952#[derive(Serialize)]
953struct ShellLostEvent {
954    event_type: &'static str,
955    event_id: String,
956    /// The prior session's own `session_start` node. Omitted from the line when that record could
957    /// not be read back — a line must never name a parent the file does not hold.
958    #[serde(skip_serializing_if = "Option::is_none")]
959    parent_id: Option<String>,
960    /// The prior session's id, not the resuming one's, so the file stays internally consistent.
961    session_id: String,
962    timestamp: u64,
963    work_id: String,
964    binary: String,
965    command: String,
966    /// The `shell_detached` line's own timestamp.
967    detached_at_ms: u64,
968    /// The session that found the work unaccounted for and reported it.
969    reconciled_by_session: String,
970    /// The `completion`-origin task that session enqueued to report it, which is what makes "which
971    /// task reported this loss" answerable from the two files alone.
972    reconciled_task_id: String,
973}
974
975/// A demotion whose own `shell_detached` line could not be written.
976///
977/// Best-effort and usually absent: it goes to the file that just failed. When it does land, it is
978/// the record that the work id below has no `shell_detached` line and so will never be reported
979/// as lost.
980#[derive(Serialize)]
981struct ShellDetachUnrecordedEvent {
982    event_type: &'static str,
983    event_id: String,
984    parent_id: Option<String>,
985    session_id: String,
986    timestamp: u64,
987    turn: u32,
988    task_id: Option<String>,
989    work_id: String,
990    binary: String,
991    /// The write error, as the operating system reported it.
992    reason: String,
993}
994
995/// A tool call the driver attached safety checks to, and how they ended. Written once per such
996/// call, after the person answered (or could not be asked) and, for an acknowledged call, before
997/// its `tool_call` record. The person's reply is never written.
998#[derive(Serialize)]
999struct SafetyCheckEvent {
1000    event_type: &'static str,
1001    event_id: String,
1002    parent_id: Option<String>,
1003    session_id: String,
1004    timestamp: u64,
1005    turn: u32,
1006    task_id: Option<String>,
1007    tool_call_id: String,
1008    tool: String,
1009    /// The block's `safety_checks` exactly as the driver sent it, a malformed one included.
1010    checks: serde_json::Value,
1011    /// `acknowledged`, `declined`, `timed_out`, `canceled`, `no_input_channel` or `malformed`.
1012    outcome: &'static str,
1013}
1014
1015#[derive(Serialize)]
1016struct CompactionEvent {
1017    event_type: &'static str,
1018    event_id: String,
1019    parent_id: Option<String>,
1020    session_id: String,
1021    timestamp: u64,
1022    turn: u32,
1023    task_id: Option<String>,
1024    /// The occupancy the trigger compared: the provider's count of the request and its
1025    /// completion, or the estimate when the call reported none.
1026    tokens_before: u64,
1027    /// Which kind of number `tokens_before` is.
1028    tokens_before_source: OccupancyBasis,
1029    /// The estimate of the committed context. No provider has counted it yet; the next
1030    /// `inference` line carries the first count.
1031    tokens_after: u64,
1032}
1033
1034/// The compaction threshold was crossed and the context was left as it was. The session
1035/// continues over budget, so the record of *why* is the only thing separating this from a
1036/// session that never needed compacting at all.
1037#[derive(Serialize)]
1038struct CompactionDeclinedEvent {
1039    event_type: &'static str,
1040    event_id: String,
1041    parent_id: Option<String>,
1042    session_id: String,
1043    timestamp: u64,
1044    turn: u32,
1045    task_id: Option<String>,
1046    /// Context occupancy at the moment of the decline — the same measurement `compaction`
1047    /// records as `tokens_before`, and the budget the session went on running over.
1048    tokens: u64,
1049    /// Which kind of number `tokens` is.
1050    tokens_source: OccupancyBasis,
1051    /// `"no_hook_replacement"` when no bound hook returned `replace-context`;
1052    /// `"unresolved_tool_call"` when the replacement's tool calls and tool results did not pair.
1053    reason: String,
1054}
1055
1056/// An `on-task-start` hook proposed context and the runtime decided what to do with it.
1057///
1058/// Written once per task that had a seeding hook return something, whatever the decision —
1059/// including a rejection, where `tokens` is `0`. A seed that was silently discarded is
1060/// indistinguishable from a capsule with no memory hook at all, which is the failure this
1061/// record exists to make impossible.
1062#[derive(Serialize)]
1063struct ContextSeedEvent {
1064    event_type: &'static str,
1065    event_id: String,
1066    parent_id: Option<String>,
1067    session_id: String,
1068    timestamp: u64,
1069    task_id: Option<String>,
1070    /// Manifest name of the hook that returned the `seed-context`.
1071    hook_name: String,
1072    /// Tokens actually committed to the head of the context: `0` for a rejection, the
1073    /// proposal's own count for `seeded`, and the surviving count for `trimmed`/`compacted`.
1074    tokens: u64,
1075    /// Tokens the hook proposed, before any trim or summarization. Together with
1076    /// `budget_tokens` this is what makes a rejection readable without the hook's own log.
1077    proposed_tokens: u64,
1078    /// The ceiling in force: `floor(context.max_tokens * context.seed_budget)`, or `0` when
1079    /// the capsule declares no `context.max_tokens`.
1080    budget_tokens: u64,
1081    /// `"seeded"`, `"trimmed"`, `"compacted"` or `"rejected"` — see the `SEED_OUTCOME_*`
1082    /// constants.
1083    outcome: String,
1084    /// Why nothing was committed, on `"rejected"` only — see the `SEED_REJECTED_*`
1085    /// constants. Absent from the JSONL for every other outcome.
1086    #[serde(skip_serializing_if = "Option::is_none")]
1087    reason: Option<String>,
1088    /// The `msg_`-prefixed id of every message committed, in the order they were placed.
1089    /// Empty on a rejection. These are the ids the runtime minted; they never reach the
1090    /// driver payload, so this record is the only place they are visible.
1091    message_ids: Vec<String>,
1092}
1093
1094/// Retention deleted something. One line per (store, reason) pair that removed anything, in the
1095/// trace of the session that performed the deletion — the only place the pruning of session N's
1096/// predecessors can go.
1097///
1098/// Session-scoped, and written immediately after `session_start`: a session directory that
1099/// vanishes with no explanation makes "where did my trace go" unanswerable, and the answer has to
1100/// be in the trace of the run that did it.
1101#[derive(Serialize)]
1102struct RetentionEvent {
1103    event_type: &'static str,
1104    event_id: String,
1105    parent_id: Option<String>,
1106    session_id: String,
1107    timestamp: u64,
1108    /// [`RETENTION_STORE_SESSIONS`] or [`RETENTION_STORE_RECORDS`].
1109    store: String,
1110    /// [`RETENTION_REASON_MAX_SESSIONS`], [`RETENTION_REASON_MAX_AGE`] or
1111    /// [`RETENTION_REASON_MAX_MESSAGES`].
1112    reason: String,
1113    /// How many units this reason removed from this store: session directories, context
1114    /// directories, or — for a truncation — the one record that was rewritten. Never zero;
1115    /// nothing removed means no event.
1116    removed: u32,
1117    /// What went: `ses_` directory names, or context ids.
1118    targets: Vec<String>,
1119    /// Messages dropped from the front of the record. Written for
1120    /// [`RETENTION_REASON_MAX_MESSAGES`] only, and absent from the JSONL otherwise.
1121    #[serde(skip_serializing_if = "Option::is_none")]
1122    messages_dropped: Option<u64>,
1123}
1124
1125#[derive(Serialize)]
1126struct A2aTaskReceivedEvent {
1127    event_type: &'static str,
1128    event_id: String,
1129    parent_id: Option<String>,
1130    session_id: String,
1131    timestamp: u64,
1132    task_id: String,
1133    context_id: String,
1134    message_id: String,
1135    traceparent_from_caller: Option<String>,
1136    /// Who the door let the task in as: [`RECEIVED_FROM_OPERATOR`], [`RECEIVED_FROM_MEMBER`],
1137    /// [`RECEIVED_FROM_CREDENTIAL`] or [`RECEIVED_FROM_PUBLIC`].
1138    caller: &'static str,
1139    /// The formation member that called, present exactly with `caller: "member"`.
1140    #[serde(skip_serializing_if = "Option::is_none")]
1141    caller_member: Option<String>,
1142    /// The declared credential whose token let the task in, present exactly with
1143    /// `caller: "credential"`.
1144    #[serde(skip_serializing_if = "Option::is_none")]
1145    caller_credential: Option<String>,
1146    /// The `sha256` of the task's message text, omitted under `capture: none`. Under `content`
1147    /// the text is stored as the blob of that name.
1148    #[serde(skip_serializing_if = "Option::is_none")]
1149    message_sha256: Option<String>,
1150}
1151
1152/// `a2a_task_received.caller` for a task the operator token let in.
1153pub(crate) const RECEIVED_FROM_OPERATOR: &str = "operator";
1154/// `a2a_task_received.caller` for a task a formation token let in.
1155pub(crate) const RECEIVED_FROM_MEMBER: &str = "member";
1156/// `a2a_task_received.caller` for a task a declared credential's token let in.
1157pub(crate) const RECEIVED_FROM_CREDENTIAL: &str = "credential";
1158/// `a2a_task_received.caller` for a task a door with no `network.authentication` let in.
1159pub(crate) const RECEIVED_FROM_PUBLIC: &str = "public";
1160
1161#[derive(Serialize)]
1162struct A2aSendEvent {
1163    event_type: &'static str,
1164    event_id: String,
1165    parent_id: Option<String>,
1166    session_id: String,
1167    timestamp: u64,
1168    peer_url: String,
1169    message_id: String,
1170    task_id: String,
1171    context_id: String,
1172    traceparent: Option<String>,
1173    /// The class this runtime stamped on the outgoing request, so a chain of capsules reads the
1174    /// same way from the sending end as the receiving end's `task_start` reads it.
1175    trust: String,
1176}
1177
1178/// A successful pull, by `manage.pull()` or `pull-artifact`: the artifact a running capsule
1179/// fetched and pinned in `murmur.lock`. A refused or failed pull writes nothing.
1180#[derive(Serialize)]
1181struct ArtifactPulledEvent {
1182    event_type: &'static str,
1183    event_id: String,
1184    parent_id: Option<String>,
1185    session_id: String,
1186    timestamp: u64,
1187    name: String,
1188    version: String,
1189    /// `"skill"` or `"tool"`, the only roles a pull installs.
1190    runtime: &'static str,
1191    /// The origin the lock entry holds after the pull: `"runtime"`, or `"operator"` when the
1192    /// operator had already pinned the same version.
1193    origin: &'static str,
1194    /// The session the lock entry names as the puller — this one, unless an earlier pull of the
1195    /// same pin recorded another. `null` for an `"operator"` entry.
1196    session: Option<String>,
1197    trust: &'static str,
1198    /// Where the payload came from: `"project store"`, `"global store"` or
1199    /// `"github:<owner>/<repo>"`.
1200    fetched_from: String,
1201    /// GitHub's numeric id of the scope's owner, written to the lock entry beside the pin.
1202    /// Absent when neither the source nor the stored payload records one.
1203    #[serde(skip_serializing_if = "Option::is_none")]
1204    owner_id: Option<u64>,
1205}
1206
1207#[derive(Serialize)]
1208struct SessionEndEvent {
1209    event_type: &'static str,
1210    event_id: String,
1211    parent_id: Option<String>,
1212    session_id: String,
1213    timestamp: u64,
1214    total_turns: u32,
1215    total_input_tokens: u64,
1216    total_output_tokens: u64,
1217    total_tool_calls: u32,
1218    total_shell_calls: u32,
1219    duration_ms: u64,
1220    exit_status: String,
1221}
1222
1223#[derive(Serialize)]
1224struct TaskStartEvent {
1225    event_type: &'static str,
1226    event_id: String,
1227    parent_id: Option<String>,
1228    session_id: String,
1229    timestamp: u64,
1230    task_id: String,
1231    context_id: String,
1232    /// Which door the task came through. `source` and `origin` answer different questions —
1233    /// `"a2a"` covers both a trusted peer and an untyped webhook.
1234    source: String,
1235    origin: String,
1236    trust: String,
1237    /// The queue lane this task waited in, derived from `origin`. Recorded so the order two
1238    /// tasks ran in is answerable from the trace alone.
1239    lane: String,
1240    message_parts_bytes: u64,
1241    /// The kind of each part of the A2A message the task arrived as, `text`, `data` or `raw`, in
1242    /// order. Absent for a task that did not arrive over the door.
1243    #[serde(skip_serializing_if = "Vec::is_empty")]
1244    part_kinds: Vec<&'static str>,
1245    /// The message's `referenceTaskIds`, each once. Absent when it referenced none.
1246    #[serde(skip_serializing_if = "Vec::is_empty")]
1247    reference_task_ids: Vec<String>,
1248    /// The images the message's `raw` parts carried, each with its `path` as
1249    /// `message.parts[<i>]`. Absent when it carried none.
1250    #[serde(skip_serializing_if = "Vec::is_empty")]
1251    attachments: Vec<AttachmentRecord>,
1252}
1253
1254#[derive(Serialize)]
1255struct TaskEndEvent {
1256    event_type: &'static str,
1257    event_id: String,
1258    parent_id: Option<String>,
1259    session_id: String,
1260    timestamp: u64,
1261    task_id: String,
1262    exit_status: String,
1263    duration_ms: u64,
1264    turns: u32,
1265    input_tokens: u64,
1266    output_tokens: u64,
1267    tool_calls: u32,
1268    shell_calls: u32,
1269    /// How many times this task's agent loop was reopened by an `on-task-end` hook
1270    /// before the terminal outcome. `0` for a task that ran once (the common case).
1271    reopen_count: u32,
1272    /// How many of this task's agent-loop turns stopped at the `inference.max_tokens` output
1273    /// cap. Always present; `0` when none did.
1274    capped_turns: u32,
1275    /// The work ids of the background shell commands still running when the task ended, oldest
1276    /// first. Absent when there were none.
1277    #[serde(skip_serializing_if = "Vec::is_empty")]
1278    running_commands: Vec<String>,
1279    /// How `lifecycle.keep_working` went for this task. Absent when the capsule does not set it.
1280    #[serde(skip_serializing_if = "Option::is_none")]
1281    keep_working: Option<KeepWorkingSummary>,
1282    /// How `lifecycle.done_check` went for this task. Absent when the capsule does not set it.
1283    #[serde(skip_serializing_if = "Option::is_none")]
1284    done_check: Option<DoneCheckSummary>,
1285}
1286
1287/// `task_end.done_check`: the runs of `lifecycle.done_check`'s command for this task, the reopens
1288/// they caused and whether the last run passed.
1289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1290pub(crate) struct DoneCheckSummary {
1291    /// Runs, across reopens.
1292    pub(crate) runs: u32,
1293    /// Reopens the failing runs caused.
1294    pub(crate) reopens: u32,
1295    /// `lifecycle.done_check.limit`.
1296    pub(crate) limit: u32,
1297    /// Whether the last run passed: `null` when the check never ran, `false` after a run a cancel
1298    /// or the session deadline stopped.
1299    pub(crate) met: Option<bool>,
1300    /// What ended the task with the check unmet: `limit`, `max_turns`, `spend_ceiling` or
1301    /// `deadline`. `null` otherwise.
1302    pub(crate) stopped_by: Option<&'static str>,
1303}
1304
1305/// `task_end.keep_working`: the continuations `lifecycle.keep_working` sent this task and what
1306/// stopped them.
1307#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1308pub(crate) struct KeepWorkingSummary {
1309    /// Continuations sent, across reopens.
1310    pub(crate) used: u32,
1311    /// `lifecycle.keep_working.limit`.
1312    pub(crate) limit: u32,
1313    /// What stopped the task being kept working: `limit`, `max_turns`, `spend_ceiling`,
1314    /// `deadline` or `no_session`. `null` when it ended on anything else — a failure, a cancel, a
1315    /// turn end that handed off work.
1316    pub(crate) stopped_by: Option<&'static str>,
1317}
1318
1319/// A person stopped this task. Written where the loop actually stopped, so the record names the
1320/// wait that was interrupted and everything that was still running at that moment.
1321///
1322/// Distinct from the `task_end` that follows it: `task_end` says the task is over and with which
1323/// `exit_status`, this says a person made that happen and what it left behind. A task cancelled
1324/// while still queued has no `task_start` and gets no `task_end`, so this is its only record.
1325#[derive(Serialize)]
1326struct TaskCanceledEvent {
1327    event_type: &'static str,
1328    event_id: String,
1329    parent_id: Option<String>,
1330    session_id: String,
1331    timestamp: u64,
1332    task_id: String,
1333    /// The turn that was in flight, 0-based. Absent for a task cancelled before it ever ran.
1334    #[serde(skip_serializing_if = "Option::is_none")]
1335    turn: Option<u32>,
1336    /// Where cancellation landed: `queued`, `turn`, `inference`, `input`, `delegation` or
1337    /// `harness`.
1338    phase: String,
1339    /// Demoted shell commands still running when the loop stopped. They keep their own
1340    /// lifecycle — nothing here was killed.
1341    detached_work_ids: Vec<String>,
1342    /// Delegations in flight when the loop stopped, named before the cancelled task ends them.
1343    delegation_ids: Vec<String>,
1344}
1345
1346/// `task_rejected.cause` when the session ended on its own — close-out, `after_task: exit`, a
1347/// failed launch task or the idle timeout — with the task still queued.
1348pub(crate) const TASK_REJECTED_SESSION_ENDED: &str = "session_ended";
1349
1350/// `task_rejected.cause` when `mur stop` (`SIGTERM`) ended the session with the task still queued.
1351pub(crate) const TASK_REJECTED_SESSION_STOPPED: &str = "session_stopped";
1352
1353/// The session stopped taking work while this task was still queued, so it was refused rather
1354/// than run. Written once per refused task, after the task loop has ended and before
1355/// `session_end`.
1356///
1357/// A refused task never started, so it has no `task_start`, no `task_end` and never a
1358/// `task_failed`: this is its only record. It hangs off the session node.
1359#[derive(Serialize)]
1360struct TaskRejectedEvent {
1361    event_type: &'static str,
1362    event_id: String,
1363    parent_id: Option<String>,
1364    session_id: String,
1365    timestamp: u64,
1366    task_id: String,
1367    context_id: String,
1368    /// The `IncomingTask::source` the task was enqueued with: `a2a`, `detached_shell` or
1369    /// `detached_lost`.
1370    source: String,
1371    /// One of the `TASK_REJECTED_*` constants.
1372    cause: String,
1373    /// The `status.message` the task's final `rejected` frame carries.
1374    reason: String,
1375}
1376
1377/// `task_failed.cause` when the driver returned an error status or a response whose
1378/// `stop_reason` is `"error"`, and no credential rejection is pending.
1379pub(crate) const TASK_FAILED_DRIVER_ERROR: &str = "driver_error";
1380
1381/// `task_failed.cause` for a driver error while the gateway holds a credential rejection: the
1382/// provider keeps refusing the key, and the reason is the `E-RUN-027` message.
1383pub(crate) const TASK_FAILED_CREDENTIAL_REJECTED: &str = "credential_rejected";
1384
1385/// `task_failed.cause` for a response the loop cannot act on: `stop_reason: "tool_call"` with no
1386/// `tool_call` block, or a `stop_reason` that is missing or unsupported.
1387pub(crate) const TASK_FAILED_MALFORMED_RESPONSE: &str = "malformed_response";
1388
1389/// `task_failed.cause` when a bound compaction hook returned `Err`.
1390pub(crate) const TASK_FAILED_COMPACTION_HOOK: &str = "compaction_hook";
1391
1392/// `task_failed.cause` when a `request-input` wait passed `lifecycle.input_timeout_secs`.
1393pub(crate) const TASK_FAILED_INPUT_TIMEOUT: &str = "input_timeout";
1394
1395/// `task_failed.cause` when an `on-task-end` hook still asked to reopen and
1396/// `lifecycle.max_task_reopens` or `inference.max_turns` refused it.
1397pub(crate) const TASK_FAILED_REOPEN_BUDGET_EXHAUSTED: &str = "reopen_budget_exhausted";
1398
1399/// `task_failed.cause` for an attempt that ended in a runtime error no other cause names. The
1400/// reason is the error's own text.
1401pub(crate) const TASK_FAILED_RUNTIME_ERROR: &str = "runtime_error";
1402
1403/// `task_failed.cause` when two agent-loop turns in a row stopped at the `inference.max_tokens`
1404/// output cap with no text and no tool call. The reason names both turns and the cap.
1405pub(crate) const TASK_FAILED_OUTPUT_CAP: &str = "output_cap";
1406
1407/// `task_failed.cause` for a task ended because a runtime write, append, read, create or removal
1408/// inside the workdir was refused. Always preceded by one `workdir_path_refused` line per refusal.
1409pub(crate) const TASK_FAILED_WORKDIR_PATH_REFUSED: &str = "workdir_path_refused";
1410
1411/// `inference.error_code` when the provider refused the inference credential: it answered `401`
1412/// and the gateway's re-read and resend were refused too. The record's `error` is the runtime's
1413/// own `E-RUN-027` message, never the provider's body.
1414pub(crate) const INFERENCE_ERROR_CREDENTIAL_REJECTED: &str = TASK_FAILED_CREDENTIAL_REJECTED;
1415
1416/// `inference.error_code` when the driver reported the call failed and no credential rejection is
1417/// pending: a provider HTTP error, the driver's own error, a gateway refusal surfaced to the
1418/// driver, or a response whose `stop_reason` is `"error"`.
1419pub(crate) const INFERENCE_ERROR_DRIVER_ERROR: &str = TASK_FAILED_DRIVER_ERROR;
1420
1421/// `inference.error_code` when the driver component could not be invoked or trapped:
1422/// instantiation or import failure, the execution deadline, or a memory limit.
1423pub(crate) const INFERENCE_ERROR_DRIVER_FAILED: &str = "driver_failed";
1424
1425/// `inference.error_code` when the driver reported success but returned no body, or a body that
1426/// is not JSON.
1427pub(crate) const INFERENCE_ERROR_MALFORMED_RESPONSE: &str = TASK_FAILED_MALFORMED_RESPONSE;
1428
1429/// `inference.error_code` when a `process` harness reported a `turn-failed` of kind `auth`.
1430pub(crate) const INFERENCE_ERROR_HARNESS_AUTH: &str = "harness_auth";
1431
1432/// `inference.error_code` when a `process` harness reported a `turn-failed` of kind `quota`.
1433pub(crate) const INFERENCE_ERROR_HARNESS_QUOTA: &str = "harness_quota";
1434
1435/// `inference.error_code` when a `process` harness reported a `turn-failed` of kind
1436/// `harness-error`.
1437pub(crate) const INFERENCE_ERROR_HARNESS_ERROR: &str = "harness_error";
1438
1439/// `inference.error_code` when a `process` harness reported a `turn-failed` of kind `other`.
1440pub(crate) const INFERENCE_ERROR_HARNESS_OTHER: &str = "harness_other";
1441
1442/// `inference.error_code` when a `process` harness went silent past the inactivity timeout.
1443pub(crate) const INFERENCE_ERROR_HARNESS_INACTIVE: &str = "harness_inactive";
1444
1445/// One model call that failed, as [`TraceWriter::write_failed_inference`] records it.
1446pub(crate) struct FailedInference<'a> {
1447    pub(crate) turn: u32,
1448    /// One of the `INFERENCE_ERROR_*` constants.
1449    pub(crate) error_code: &'static str,
1450    /// The failure in words, already redacted. Capped by the writer.
1451    pub(crate) error: &'a str,
1452    /// The HTTP status the credential gateway received for this call, when it received one.
1453    pub(crate) provider_status: Option<u16>,
1454    /// What the provider itself reported, when the failure carried a `usage` block.
1455    pub(crate) usage: Option<&'a DriverUsage>,
1456    /// `Some` for a hook's `run-inference`; `None` for an agent-loop call.
1457    pub(crate) origin: Option<&'a InferenceOrigin>,
1458    /// The driver choice the call was dispatched to, on a capsule that declares alternates.
1459    pub(crate) choice: Option<&'a crate::driver_choice::DriverChoice>,
1460    /// The messages the failed request embedded.
1461    pub(crate) message_ids: Vec<String>,
1462    pub(crate) tool_name: Option<String>,
1463    /// `(input_tokens, output_tokens)` this call counts toward the turn and token totals. `None`
1464    /// is a call that is not counted at all — no turn, no tokens, and neither key on the record.
1465    pub(crate) spent: Option<(Option<u64>, Option<u64>)>,
1466    /// How the call was sent; `None` on the `process` transport.
1467    pub(crate) settings: Option<&'a InferenceCallSettings>,
1468    /// The call's attempts and why its retrying stopped, as its retry loop ended.
1469    pub(crate) retry: RetryNote,
1470    /// The last attempt's gateway timing; `None` for a call made through no runtime gateway.
1471    pub(crate) gateway: Option<GatewayOffsets>,
1472}
1473
1474/// One `inference` line, as the writers that share [`TraceWriter::write_inference_event`] hand
1475/// it over.
1476struct InferenceLine<'a> {
1477    turn: u32,
1478    input_tokens: Option<u64>,
1479    output_tokens: Option<u64>,
1480    decision: String,
1481    stop_reason: Option<&'a str>,
1482    tool_name: Option<String>,
1483    origin: Option<&'a InferenceOrigin>,
1484    usage: Option<&'a DriverUsage>,
1485    message_ids: Vec<String>,
1486    wire: Option<&'a WireCapture>,
1487    choice: Option<&'a crate::driver_choice::DriverChoice>,
1488    /// `(error_code, error, provider_status)` for a failed call.
1489    failure: Option<(&'static str, String, Option<u16>)>,
1490    /// Whether the line advances the turn and token counters.
1491    counted: bool,
1492    /// How the call was sent; `None` on the `process` transport, which writes neither key.
1493    settings: Option<&'a InferenceCallSettings>,
1494    retry: RetryNote,
1495    /// When the attempt's request reached the runtime's credential gateway and was answered;
1496    /// `None` for a call made through no runtime gateway, which writes neither key.
1497    gateway: Option<GatewayOffsets>,
1498}
1499
1500/// The most `task_failed.reason` carries, in bytes. A driver's error text is provider-supplied
1501/// and unbounded; the whole of it stays in `out/result.txt`.
1502pub(crate) const MAX_TASK_FAILURE_REASON_BYTES: usize = 2_000;
1503
1504/// One runtime write, append, read, create or removal inside the workdir that was refused and did
1505/// nothing. Written whatever `trace.capture` is; parented to the
1506/// task node in a task and to the session node outside one.
1507#[derive(Serialize)]
1508struct WorkdirPathRefusedEvent<'a> {
1509    event_type: &'static str,
1510    event_id: String,
1511    parent_id: Option<String>,
1512    session_id: String,
1513    timestamp: u64,
1514    #[serde(skip_serializing_if = "Option::is_none")]
1515    task_id: Option<String>,
1516    /// The path the runtime meant to touch, relative to the accessible workdir.
1517    path: &'a str,
1518    operation: crate::workdir_fs::WorkdirOperation,
1519    /// The component that was refused, relative to the accessible workdir; the whole path for an
1520    /// invalid component.
1521    refused_at: &'a str,
1522    reason: crate::workdir_fs::RefusalReason,
1523}
1524
1525/// A task attempt failed, and why. Written once per failing attempt, before its task's
1526/// `task_end`, by whichever part of the runtime saw the failure.
1527///
1528/// `task_end.exit_status` says `failed`; this names the cause from a closed vocabulary (the
1529/// `TASK_FAILED_*` constants) and carries the reason in words. Never written for a task that
1530/// ended `ok`, `max_turns_reached`, `spend_ceiling_reached` or `canceled`: those have their own
1531/// records.
1532#[derive(Serialize)]
1533struct TaskFailedEvent {
1534    event_type: &'static str,
1535    event_id: String,
1536    parent_id: Option<String>,
1537    session_id: String,
1538    timestamp: u64,
1539    /// The task in progress. Absent for a failure recorded before any `task_start`.
1540    #[serde(skip_serializing_if = "Option::is_none")]
1541    task_id: Option<String>,
1542    /// The turn that failed, 0-based. Absent when the failure is the task's rather than one
1543    /// turn's.
1544    #[serde(skip_serializing_if = "Option::is_none")]
1545    turn: Option<u32>,
1546    cause: String,
1547    /// Capped at [`MAX_TASK_FAILURE_REASON_BYTES`] on a character boundary.
1548    reason: String,
1549}
1550
1551/// `task_reopened.attempt_context` for a next attempt that continues the task's own
1552/// conversation, with the hook's feedback as the one message it adds.
1553pub(crate) const REOPEN_CONTEXT_CONTINUED: &str = "continued";
1554
1555/// `task_reopened.attempt_context` for a next attempt that starts from a fresh context built
1556/// from the rewritten `task.md`, because the previous attempt left no conversation to continue.
1557pub(crate) const REOPEN_CONTEXT_RESTARTED: &str = "restarted";
1558
1559/// One `on-task-end` hook reopened the task: its agent loop is about to run again with
1560/// the hook's feedback. Written between two agent-loop attempts for the same task, so
1561/// `mur trace show` can show which hook drove each reopen, why, and what the next attempt
1562/// starts from.
1563#[derive(Serialize)]
1564struct TaskReopenedEvent {
1565    event_type: &'static str,
1566    event_id: String,
1567    parent_id: Option<String>,
1568    session_id: String,
1569    timestamp: u64,
1570    task_id: String,
1571    /// Manifest name of the `on-task-end` hook that returned `reopen-task`.
1572    hook_name: String,
1573    /// Feedback text the hook asked to inject into the reopened task content.
1574    reason: String,
1575    /// 1-based ordinal of this reopen within the task (first reopen = 1).
1576    reopen_number: u32,
1577    /// [`REOPEN_CONTEXT_CONTINUED`] or [`REOPEN_CONTEXT_RESTARTED`].
1578    attempt_context: &'static str,
1579    /// The turns the next attempt is handed: `inference.max_turns` less every turn the task's
1580    /// attempts have spent so far. `null` when
1581    /// `inference.max_turns` sets no cap.
1582    turns_remaining: Option<u32>,
1583}
1584
1585/// A task continued once with everything it had handed off: its attempt ended with a turn left,
1586/// it waited until no `call-member` call and no `delegate-task` delegation was outstanding, and
1587/// the next attempt is handed every answer and outcome in one message. Written after that round's
1588/// `member_call` and `delegation` lines and before the continued attempt's first `inference`.
1589#[derive(Serialize)]
1590struct TaskContinuedEvent<'a> {
1591    event_type: &'static str,
1592    event_id: String,
1593    parent_id: Option<String>,
1594    session_id: String,
1595    timestamp: u64,
1596    task_id: String,
1597    /// 1-based ordinal of this continuation within the task, counted across reopens.
1598    continuation_number: u32,
1599    /// The `mcl_` id of every call whose outcome this continuation delivers, in arrival order.
1600    member_calls: &'a [String],
1601    /// The `dlg_` id of every delegation whose outcome this continuation delivers, arrived or
1602    /// ended by the backstop, in id order.
1603    delegations: &'a [String],
1604    /// How long the task waited, from its attempt's end until nothing was outstanding.
1605    waited_ms: u64,
1606    /// The turns the continued attempt is handed: `inference.max_turns` less every turn the
1607    /// task's attempts have spent so far. `null` when
1608    /// `inference.max_turns` sets no cap.
1609    turns_remaining: Option<u32>,
1610}
1611
1612/// `lifecycle.keep_working` sent its message after the task's attempt ended at a turn end. Written
1613/// after that turn's `inference` and before the continued attempt's first one.
1614#[derive(Serialize)]
1615struct KeepWorkingEvent {
1616    event_type: &'static str,
1617    event_id: String,
1618    parent_id: Option<String>,
1619    session_id: String,
1620    timestamp: u64,
1621    task_id: String,
1622    /// 1-based ordinal of this continuation within the task, counted across reopens.
1623    number: u32,
1624    /// `lifecycle.keep_working.limit`.
1625    limit: u32,
1626    /// The turns the continued attempt is handed: `inference.max_turns` less every turn the
1627    /// task's attempts have spent so far. `null` when `inference.max_turns` sets no cap.
1628    turns_remaining: Option<u32>,
1629    /// The byte length of the message sent.
1630    message_bytes: u64,
1631}
1632
1633/// One run of `lifecycle.done_check`'s command at a turn end. Written after that turn's
1634/// `inference` and any `keep_working` record, before the next attempt's first `inference` or the
1635/// task's `task_end`.
1636#[derive(Serialize)]
1637struct DoneCheckEvent<'a> {
1638    event_type: &'static str,
1639    event_id: String,
1640    parent_id: Option<String>,
1641    session_id: String,
1642    timestamp: u64,
1643    #[serde(flatten)]
1644    run: &'a DoneCheckRun<'a>,
1645}
1646
1647/// The fields of one `done_check` record that describe the run.
1648#[derive(Debug, Serialize)]
1649pub(crate) struct DoneCheckRun<'a> {
1650    pub(crate) task_id: &'a str,
1651    /// 1-based ordinal of this run within the task, counted across reopens.
1652    pub(crate) number: u32,
1653    /// `lifecycle.done_check.command` as declared.
1654    pub(crate) command: &'a str,
1655    /// `passed`, `failed`, `timed_out`, `stopped` or `not_started`.
1656    pub(crate) outcome: &'static str,
1657    /// `null` for `timed_out`, `stopped` and `not_started`.
1658    pub(crate) exit_code: Option<i32>,
1659    pub(crate) duration_ms: u64,
1660    /// Bytes returned after the 16 KiB-per-stream cap.
1661    pub(crate) stdout_bytes: u64,
1662    pub(crate) stderr_bytes: u64,
1663    pub(crate) truncated: bool,
1664    /// The `capabilities.resources` field whose limit killed the command.
1665    pub(crate) resource_limit: Option<&'a str>,
1666    /// Whether this run reopened the task.
1667    pub(crate) reopened: bool,
1668    /// The reopens the check may still cause in this task, after this run.
1669    pub(crate) reopens_left: u32,
1670    /// `inference.max_turns` less every turn the task has spent. `null` with no cap.
1671    pub(crate) turns_remaining: Option<u32>,
1672}
1673
1674/// A component call the wall-clock bound on `capabilities.limits.deadline_seconds` stopped while
1675/// it waited on the host. Written once per firing, before the record of the call it cut and
1676/// before `session_end`; never for an epoch trap.
1677#[derive(Serialize)]
1678struct CallDeadlineEvent {
1679    event_type: &'static str,
1680    event_id: String,
1681    parent_id: Option<String>,
1682    session_id: String,
1683    timestamp: u64,
1684    #[serde(skip_serializing_if = "Option::is_none")]
1685    task_id: Option<String>,
1686    /// [`crate::limits::CallDeadline::component`].
1687    component: String,
1688    /// [`crate::limits::ComponentRole::wire_name`].
1689    role: &'static str,
1690    /// [`crate::limits::CallDeadline::call`].
1691    call: String,
1692    deadline_seconds: u64,
1693    /// How long the stopped phase had run, from the start of that phase.
1694    elapsed_ms: u64,
1695}
1696
1697/// A driver call failed transiently and is about to be made again. The call's one `inference`
1698/// line follows its last attempt.
1699#[derive(Serialize)]
1700struct InferenceRetryEvent<'a> {
1701    event_type: &'static str,
1702    event_id: String,
1703    parent_id: Option<String>,
1704    session_id: String,
1705    timestamp: u64,
1706    turn: u32,
1707    task_id: Option<String>,
1708    attempt: u32,
1709    max_retries: u32,
1710    reason: &'static str,
1711    #[serde(skip_serializing_if = "Option::is_none")]
1712    provider_status: Option<u16>,
1713    error: &'a str,
1714    delay_ms: u64,
1715    #[serde(skip_serializing_if = "Option::is_none")]
1716    retry_after_ms: Option<u64>,
1717    charged_tokens: u64,
1718    #[serde(skip_serializing_if = "Option::is_none")]
1719    origin: Option<&'a str>,
1720}
1721
1722/// A spend ceiling refused a driver call before it was sent. No `inference` line accompanies it,
1723/// because no call was made.
1724#[derive(Serialize)]
1725struct SpendCeilingReachedEvent<'a> {
1726    event_type: &'static str,
1727    event_id: String,
1728    parent_id: Option<String>,
1729    session_id: String,
1730    timestamp: u64,
1731    turn: u32,
1732    task_id: Option<String>,
1733    #[serde(flatten)]
1734    refusal: &'a crate::spend::SpendRefusal,
1735    /// `hook:<name>` for a hook's `run-inference` call; absent for an agent-loop turn.
1736    #[serde(skip_serializing_if = "Option::is_none")]
1737    origin: Option<&'a str>,
1738}
1739
1740/// What a `transport: process` session spawned, and what the driver planned for it.
1741///
1742/// Separate from [`HarnessStartEvent`] so the runner builds one value rather than passing a dozen
1743/// positional arguments. Flattened into the event, so the record has no nested object.
1744#[derive(Serialize)]
1745pub(crate) struct HarnessStart {
1746    /// The process driver artifact and its version.
1747    pub driver: String,
1748    pub driver_version: String,
1749    /// What the driver said it drives, and the executable actually spawned.
1750    pub harness: String,
1751    /// The absolute path spawned — not the name the manifest or the driver asked for.
1752    pub binary: String,
1753    /// `inference.command`, or the process driver's `describe()`.
1754    pub binary_source: String,
1755    /// What the harness printed for its version arguments, or `null` when it could not be read.
1756    pub harness_version: Option<String>,
1757    /// Whether [`Self::harness_version`] matched one of the driver's `tested-versions`.
1758    pub version_tested: bool,
1759    /// The harness session id the runtime minted for this run, and whether it started or resumed.
1760    pub harness_session_id: String,
1761    pub session_mode: String,
1762    /// How many arguments the plan carried. Never the arguments themselves: a driver is free to
1763    /// put the bridge's bearer token in one.
1764    pub args_count: usize,
1765    /// The names of the variables actually delivered, sorted. Never their values.
1766    pub env_names: Vec<String>,
1767    /// Which of `USER` and `LOGNAME` the host did not set, sorted; empty when it set both. Never
1768    /// their values.
1769    pub account_env_unset: Vec<String>,
1770    /// The names of the files written into the run's private directory. Never their contents.
1771    pub files: Vec<String>,
1772    /// The capsule tool names the bridge offered, bare. Empty when no bridge was bound.
1773    pub bridge_tools: Vec<String>,
1774    /// How many bytes were written to the harness's stdin, and whether it stayed open after.
1775    pub stdin_bytes: usize,
1776    pub keep_stdin_open: bool,
1777    /// SHA-256 (lowercase hex) of the exact system prompt handed to the process driver: the
1778    /// fact lines, the resolved prompt and any instruction-file blocks. Unlike
1779    /// `session_start.system_prompt_sha256`, which names the prompt the operator wrote.
1780    pub system_prompt_sha256: String,
1781    /// The text [`Self::system_prompt_sha256`] hashes, stored as that blob under
1782    /// `trace.capture: content` and never written to the line.
1783    #[serde(skip)]
1784    pub system_prompt: String,
1785}
1786
1787#[derive(Serialize)]
1788struct HarnessStartEvent {
1789    event_type: &'static str,
1790    event_id: String,
1791    parent_id: Option<String>,
1792    session_id: String,
1793    timestamp: u64,
1794    task_id: Option<String>,
1795    #[serde(flatten)]
1796    start: HarnessStart,
1797}
1798
1799/// A `W-*` warning the run raised. The same text goes to stderr; this is the durable copy, so a
1800/// run read back later still says what it warned about.
1801#[derive(Serialize)]
1802struct HarnessWarningEvent {
1803    event_type: &'static str,
1804    event_id: String,
1805    parent_id: Option<String>,
1806    session_id: String,
1807    timestamp: u64,
1808    task_id: Option<String>,
1809    code: String,
1810    message: String,
1811}
1812
1813/// The session the harness itself reports, which is not this session: the runtime's `session_id`
1814/// names the capsule run, `harness_session_id` names the conversation inside the harness.
1815#[derive(Serialize)]
1816struct HarnessSessionEvent {
1817    event_type: &'static str,
1818    event_id: String,
1819    parent_id: Option<String>,
1820    session_id: String,
1821    timestamp: u64,
1822    task_id: Option<String>,
1823    harness_session_id: String,
1824    /// How the harness says it is billing. Anything but `subscription` is also warned about.
1825    auth: String,
1826    /// The model the harness says it is running, when it says.
1827    model: Option<String>,
1828}
1829
1830/// The harness session a person asked this capsule to drop, recorded because forgetting is not
1831/// the same as never having known: the turn that follows starts a new conversation under a context
1832/// that had one, and this is what says so.
1833#[derive(Serialize)]
1834struct HarnessSessionForgottenEvent {
1835    event_type: &'static str,
1836    event_id: String,
1837    parent_id: Option<String>,
1838    session_id: String,
1839    timestamp: u64,
1840    task_id: Option<String>,
1841    /// The context whose entry was dropped.
1842    context_id: String,
1843    /// The id that was dropped, which the harness is no longer asked to continue.
1844    harness_session_id: String,
1845    /// Which surface carried the request: `cli` or `a2a`.
1846    requested_by: String,
1847}
1848
1849#[derive(Serialize)]
1850struct HarnessRetryEvent {
1851    event_type: &'static str,
1852    event_id: String,
1853    parent_id: Option<String>,
1854    session_id: String,
1855    timestamp: u64,
1856    task_id: Option<String>,
1857    attempt: u32,
1858    reason: String,
1859}
1860
1861/// A task-node record whose payload is one line of text: `harness_note` and
1862/// `harness_suggestion`.
1863#[derive(Serialize)]
1864struct HarnessTextEvent {
1865    event_type: &'static str,
1866    event_id: String,
1867    parent_id: Option<String>,
1868    session_id: String,
1869    timestamp: u64,
1870    task_id: Option<String>,
1871    text: String,
1872}
1873
1874/// One subscription window the harness reported, as `harness_quota` records it. A member the
1875/// harness did not report is left out, never written as zero or `null`.
1876#[derive(Serialize)]
1877struct HarnessQuotaWindow<'a> {
1878    name: &'a str,
1879    #[serde(skip_serializing_if = "Option::is_none")]
1880    used: Option<f64>,
1881    #[serde(skip_serializing_if = "Option::is_none")]
1882    resets_at_ms: Option<u64>,
1883    #[serde(skip_serializing_if = "Option::is_none")]
1884    window_minutes: Option<u64>,
1885    #[serde(skip_serializing_if = "Option::is_none")]
1886    state: Option<&'static str>,
1887}
1888
1889#[derive(Serialize)]
1890struct HarnessQuotaEvent<'a> {
1891    event_type: &'static str,
1892    event_id: String,
1893    parent_id: Option<String>,
1894    session_id: String,
1895    timestamp: u64,
1896    task_id: Option<String>,
1897    windows: Vec<HarnessQuotaWindow<'a>>,
1898}
1899
1900#[derive(Serialize)]
1901struct HarnessFailedEvent {
1902    event_type: &'static str,
1903    event_id: String,
1904    parent_id: Option<String>,
1905    session_id: String,
1906    timestamp: u64,
1907    task_id: Option<String>,
1908    /// The driver's `failure-kind`, spelled as the WIT spells it, or `max-turns` from the runtime.
1909    kind: String,
1910    message: String,
1911    /// `harness` when the harness reported it, `runtime` when this runtime ended the turn.
1912    source: String,
1913}
1914
1915/// How the runtime tried to stop a harness a person cancelled the task of.
1916#[derive(Serialize)]
1917struct HarnessInterruptEvent {
1918    event_type: &'static str,
1919    event_id: String,
1920    parent_id: Option<String>,
1921    session_id: String,
1922    timestamp: u64,
1923    task_id: Option<String>,
1924    /// The driver's `interrupt-method`, spelled as the WIT spells it.
1925    method: String,
1926    /// Whether a graceful interrupt actually reached the harness.
1927    delivered: bool,
1928    /// How long the harness has to end on its own before it is killed. `0` when it is killed at
1929    /// once, which is every ending where `delivered` is false.
1930    grace_ms: u64,
1931}
1932
1933/// How one spawned harness ended. Written once per spawn, on every path out of the run.
1934#[derive(Serialize)]
1935pub(crate) struct HarnessExit {
1936    /// The process's exit code, or `null` when a signal ended it.
1937    pub code: Option<i32>,
1938    /// The signal that ended the process, or `null` when it exited on its own.
1939    pub signal: Option<i32>,
1940    /// What ended the run: `terminal`, `eof`, `inactivity`, `max_turns`, `driver_error` or
1941    /// `canceled`.
1942    pub cause: String,
1943    /// Whether the runtime had to kill the process rather than wait for it.
1944    pub killed: bool,
1945    /// Wall time from spawn to reaped.
1946    pub duration_ms: u64,
1947}
1948
1949#[derive(Serialize)]
1950struct HarnessExitEvent {
1951    event_type: &'static str,
1952    event_id: String,
1953    parent_id: Option<String>,
1954    session_id: String,
1955    timestamp: u64,
1956    task_id: Option<String>,
1957    #[serde(flatten)]
1958    exit: HarnessExit,
1959}
1960
1961/// A policy hook refused a call before it ran. The one record that says a call the model asked
1962/// for never happened — there is no `tool_call` or `shell` line for a denied call, because
1963/// nothing ran.
1964#[derive(Serialize)]
1965struct CallDeniedEvent {
1966    event_type: &'static str,
1967    event_id: String,
1968    parent_id: Option<String>,
1969    session_id: String,
1970    timestamp: u64,
1971    turn: u32,
1972    /// The gated lifecycle function whose decision point refused: `"on-shell"` or
1973    /// `"on-tool-call"`.
1974    event: String,
1975    /// Manifest name of the policy hook that refused.
1976    hook_name: String,
1977    /// What was refused: the resolved executable path for a shell call, the tool name
1978    /// otherwise.
1979    target: String,
1980    /// The hook's own reason, or the runtime's when the hook supplied none it could use.
1981    reason: String,
1982}
1983
1984/// A tool call was refused before it ran because its input lacked a name the tool's
1985/// `input_schema` lists in `required`. Written in every capture mode: under the default `meta`
1986/// the accompanying `tool_call` record carries no `output`, so this is where the reason lands.
1987#[derive(Serialize)]
1988struct ToolInputRefusedEvent {
1989    event_type: &'static str,
1990    event_id: String,
1991    parent_id: Option<String>,
1992    session_id: String,
1993    timestamp: u64,
1994    turn: u32,
1995    tool_name: String,
1996    /// The provider's id for the call, or `null` where the call arrived without one — every
1997    /// call bridged from a `transport: process` harness.
1998    tool_call_id: Option<String>,
1999    /// The required names the input lacked, in the schema's declared order.
2000    missing: Vec<String>,
2001    /// The refusal text the model was handed.
2002    reason: String,
2003}
2004
2005/// The manifest's own read-only rule refused a call before it ran. Distinct from
2006/// [`CallDeniedEvent`] because it names a different authority and a different subject: no hook was
2007/// asked, and what it carries is the path, the declared rule and the evidence that identified the
2008/// call as a write.
2009#[derive(Serialize)]
2010struct ProtectedPathDeniedEvent {
2011    event_type: &'static str,
2012    event_id: String,
2013    parent_id: Option<String>,
2014    session_id: String,
2015    timestamp: u64,
2016    turn: u32,
2017    /// Which dispatch path was refused: `"shell"` or `"tool"`.
2018    call: String,
2019    /// What was refused: the resolved executable path for a shell call, the tool name otherwise.
2020    target: String,
2021    /// The resolved workdir-relative path, never the string the model typed, so two spellings of
2022    /// one file produce one comparable record.
2023    path: String,
2024    /// The `capabilities.filesystem.read_only` entry that covers `path`, as declared.
2025    rule: String,
2026    /// What identified the call as a write — a redirection operator, a write-verb argument
2027    /// position, or the tool-input key pairing.
2028    signal: String,
2029    /// The same text the model was given, so the trace and the agent agree on why.
2030    reason: String,
2031}
2032
2033#[derive(Serialize)]
2034struct HookDispatchErrorEvent {
2035    event_type: &'static str,
2036    event_id: String,
2037    parent_id: Option<String>,
2038    session_id: String,
2039    timestamp: u64,
2040    hook_name: String,
2041    event: String,
2042    arm: String,
2043}
2044
2045/// A formation member's lifeline read EOF: the formation it belongs to has ended, and this
2046/// session is winding down because of it. Written only when the lifeline is what began the
2047/// session's termination.
2048#[derive(Serialize)]
2049struct FormationEndedEvent {
2050    event_type: &'static str,
2051    event_id: String,
2052    parent_id: Option<String>,
2053    session_id: String,
2054    timestamp: u64,
2055    formation_id: String,
2056}
2057
2058/// One `session-status` call: the object the model was handed, measured at the call.
2059#[derive(Serialize)]
2060struct SessionStatusEvent<'a> {
2061    event_type: &'static str,
2062    event_id: String,
2063    parent_id: Option<String>,
2064    session_id: String,
2065    timestamp: u64,
2066    /// The task that made the call. Absent outside a task.
2067    #[serde(skip_serializing_if = "Option::is_none")]
2068    task_id: Option<String>,
2069    status: &'a crate::session_status::StatusReport,
2070}
2071
2072/// The one deadline notice, appended to the tool result named here.
2073#[derive(Serialize)]
2074struct DeadlineNoticeEvent<'a> {
2075    event_type: &'static str,
2076    event_id: String,
2077    parent_id: Option<String>,
2078    session_id: String,
2079    timestamp: u64,
2080    /// The task whose tool result carried it. Absent outside a task.
2081    #[serde(skip_serializing_if = "Option::is_none")]
2082    task_id: Option<String>,
2083    /// The tool whose result carried it.
2084    tool_name: &'a str,
2085    /// When the runtime ends the session, RFC 3339 UTC.
2086    deadline_at: &'a str,
2087    /// Whole seconds left when the result was handed back.
2088    left_secs: u64,
2089    /// The margin it was due under.
2090    notice_secs: u64,
2091}
2092
2093/// The session reached its deadline, own or inherited, and is winding down because of it. Written
2094/// by the termination routine before any task is cancelled.
2095#[derive(Serialize)]
2096struct DeadlineReachedEvent {
2097    event_type: &'static str,
2098    event_id: String,
2099    parent_id: Option<String>,
2100    session_id: String,
2101    timestamp: u64,
2102    /// When the deadline fell, RFC 3339 UTC.
2103    deadline_at: String,
2104    /// `session_start.deadline.secs`.
2105    deadline_secs: u64,
2106    /// `SessionDeadline::source`: `"own"` or `"inherited"`.
2107    source: &'static str,
2108}
2109
2110/// A delegated child's spawner lifeline read EOF: the process that spawned this session has
2111/// ended, and this session is winding down because of it. Written only when that lifeline is what
2112/// began the session's termination.
2113///
2114/// The lineage keys are the ones `session_start` carries, and like there they are absent rather
2115/// than null for a session launched with a lifeline but no `MURMUR_SPAWNER`.
2116#[derive(Serialize)]
2117struct SpawnerEndedEvent {
2118    event_type: &'static str,
2119    event_id: String,
2120    parent_id: Option<String>,
2121    session_id: String,
2122    timestamp: u64,
2123    #[serde(skip_serializing_if = "Option::is_none")]
2124    spawned_by: Option<String>,
2125    #[serde(skip_serializing_if = "Option::is_none")]
2126    delegation_id: Option<String>,
2127}
2128
2129#[derive(Serialize)]
2130struct ResourceListEvent {
2131    event_type: &'static str,
2132    event_id: String,
2133    parent_id: Option<String>,
2134    session_id: String,
2135    timestamp: u64,
2136    /// `exports.files.root` verbatim, or `""` when the capsule declares no export — a refusal on
2137    /// an undeclared plane is still a complete record of what was asked for.
2138    root: String,
2139    entry_count: usize,
2140    total_bytes: u64,
2141    /// Which completed turn the listing is as of.
2142    generation: u64,
2143    containment_achieved: ContainmentClass,
2144    /// `"ok"`, or the wire error code the request was refused with.
2145    outcome: String,
2146    /// `null` on `ok`, one short sentence otherwise.
2147    reason: Option<String>,
2148}
2149
2150#[derive(Serialize)]
2151struct ResourceReadEvent {
2152    event_type: &'static str,
2153    event_id: String,
2154    parent_id: Option<String>,
2155    session_id: String,
2156    timestamp: u64,
2157    /// The requested path *after* percent-decoding, so `%2e%2e%2f` and `../` read as the same
2158    /// attempt rather than as two unrelated findings.
2159    path: String,
2160    outcome: String,
2161    /// `null` on every non-`ok` outcome.
2162    bytes: Option<u64>,
2163    /// `null` on every non-`ok` outcome.
2164    sha256: Option<String>,
2165    generation: u64,
2166    containment_achieved: ContainmentClass,
2167    reason: Option<String>,
2168}
2169
2170/// One `share-file` call, served or refused, written at the moment of the mint.
2171#[derive(Serialize)]
2172struct PeerHandleMintEvent {
2173    event_type: &'static str,
2174    event_id: String,
2175    parent_id: Option<String>,
2176    session_id: String,
2177    timestamp: u64,
2178    /// `null` on every non-`ok` outcome: a mint that was refused produced no token, so there is
2179    /// no handle to identify.
2180    handle_id: Option<String>,
2181    /// The path relative to `exports.peer_files.root`, canonicalised, or the path the agent asked
2182    /// for when the mint was refused. Never a host path and never the workdir-relative form.
2183    path: String,
2184    audience: String,
2185    /// `null` on every non-`ok` outcome.
2186    expires_at_ms: Option<u64>,
2187    outcome: String,
2188    reason: Option<String>,
2189}
2190
2191/// One redeem against `/resources/peer/<handle>`, written by the listener concurrently with a
2192/// possibly-running task.
2193#[derive(Serialize)]
2194struct PeerHandleRedeemEvent {
2195    event_type: &'static str,
2196    event_id: String,
2197    parent_id: Option<String>,
2198    session_id: String,
2199    timestamp: u64,
2200    handle_id: String,
2201    /// `null` until the MAC has verified. A payload that failed the MAC is caller-controlled and
2202    /// must not be copied into this capsule's own audit record as if it were fact.
2203    path: Option<String>,
2204    /// The runtime's own current counter, never a value taken from the token — so it is always
2205    /// present and always true.
2206    generation: u64,
2207    /// The `x-murmur-audience` header exactly as asserted, or `null` when none was sent.
2208    audience_asserted: Option<String>,
2209    /// `null` on every non-`ok` outcome.
2210    bytes: Option<u64>,
2211    /// `null` on every non-`ok` outcome.
2212    sha256: Option<String>,
2213    outcome: String,
2214    reason: Option<String>,
2215}
2216
2217/// One `fetch-peer-file` call on the ingesting side, served or refused.
2218#[derive(Serialize)]
2219struct PeerFileFetchEvent {
2220    event_type: &'static str,
2221    event_id: String,
2222    parent_id: Option<String>,
2223    session_id: String,
2224    timestamp: u64,
2225    peer: String,
2226    handle_id: String,
2227    /// Where the bytes landed, relative to the accessible workdir. `null` on every non-`ok`
2228    /// outcome.
2229    stored_path: Option<String>,
2230    bytes: Option<u64>,
2231    sha256: Option<String>,
2232    outcome: String,
2233    reason: Option<String>,
2234}
2235
2236/// One launched child, written the moment it is up.
2237///
2238/// Two surfaces launch children — the agent's `delegate-task` tool and a plan's `capsule` step —
2239/// and both write this line, off the same struct and off the same session node. Which one made a
2240/// given delegation is not recorded, because it is not a fact about the delegation.
2241///
2242/// Separate from the `delegation` line below because the two answer different questions and land
2243/// at different times: this one reaches disk while the delegation is still in flight, so a child
2244/// that hangs, crashes or is ended at its deadline is attributable from the parent's side whatever
2245/// happens next. A delegation the daemon refused writes none of these — nothing was launched.
2246#[derive(Serialize)]
2247struct DelegationStartEvent {
2248    event_type: &'static str,
2249    event_id: String,
2250    parent_id: Option<String>,
2251    session_id: String,
2252    timestamp: u64,
2253    /// The `dlg_` id the launcher minted, character-identical to the one on this delegation's
2254    /// terminal `delegation` line and in the child's own `session_start`.
2255    delegation_id: String,
2256    capsule: String,
2257    version: String,
2258    /// The session id the child's runtime minted for itself.
2259    child_session_id: String,
2260    /// The child's directory, relative to this capsule's accessible workdir.
2261    child_workdir: String,
2262    /// The child's records directory, absolute, as the child reported it: its own `trace.jsonl`
2263    /// and `completion.json` are there.
2264    child_records_dir: String,
2265    /// What was copied from this capsule's work folder into the child's before it started, or
2266    /// `null` for a child not listed under `capabilities.spawn.workdir_snapshot`. Always
2267    /// serialized.
2268    workdir_snapshot: Option<crate::workdir_snapshot::WorkdirSnapshot>,
2269}
2270
2271impl DelegationStartEvent {
2272    /// The one place this record is built, so the two appenders that write it cannot drift into
2273    /// two shapes. Which surface launched the child is not an argument, because it is not a field.
2274    #[allow(clippy::too_many_arguments)]
2275    fn new(
2276        session_id: &str,
2277        session_event_id: &str,
2278        delegation_id: &str,
2279        capsule: &str,
2280        version: &str,
2281        child_session_id: &str,
2282        child_workdir: &str,
2283        child_records_dir: &str,
2284        workdir_snapshot: Option<&crate::workdir_snapshot::WorkdirSnapshot>,
2285    ) -> Self {
2286        Self {
2287            event_type: "delegation_start",
2288            event_id: new_event_id(),
2289            parent_id: Some(session_event_id.to_string()),
2290            session_id: session_id.to_string(),
2291            timestamp: timestamp_ms(),
2292            delegation_id: delegation_id.to_string(),
2293            capsule: capsule.to_string(),
2294            version: version.to_string(),
2295            child_session_id: child_session_id.to_string(),
2296            child_workdir: child_workdir.to_string(),
2297            child_records_dir: child_records_dir.to_string(),
2298            workdir_snapshot: workdir_snapshot.cloned(),
2299        }
2300    }
2301}
2302
2303/// A `call-member` call whose callee holds the task, written as the tool call returns — or later,
2304/// by the call's watcher, when a callee that was busy takes the task.
2305///
2306/// Carries the callee's roster name and the id of the task its door holds, never its door's URL
2307/// or the token the call presented.
2308#[derive(Serialize)]
2309struct MemberCallStartEvent {
2310    event_type: &'static str,
2311    event_id: String,
2312    parent_id: Option<String>,
2313    session_id: String,
2314    timestamp: u64,
2315    /// The calling task.
2316    task_id: String,
2317    /// The `mcl_` id the call returned to the model.
2318    call_id: String,
2319    /// The callee's roster name.
2320    member: String,
2321    /// The task the callee's door holds, as its `GetTask` names it.
2322    member_task_id: String,
2323}
2324
2325/// One offer of a `call-member` call that the callee's door turned away busy: it answered
2326/// `SendMessage` with `rejected` and [`crate::a2a::REJECTED_BUSY_MESSAGE`]. The first is written
2327/// as the tool call returns, each later one by the call's watcher as it offers the task again.
2328///
2329/// Carries the callee's roster name, never its door's URL or the token the call presented.
2330#[derive(Serialize)]
2331struct MemberCallBusyEvent {
2332    event_type: &'static str,
2333    event_id: String,
2334    parent_id: Option<String>,
2335    session_id: String,
2336    timestamp: u64,
2337    /// The calling task.
2338    task_id: String,
2339    /// The `mcl_` id the call returned to the model.
2340    call_id: String,
2341    /// The callee's roster name.
2342    member: String,
2343    /// Which offer of the call this was, from 1.
2344    offer: u32,
2345    /// From the tool call that made the call to this refusal.
2346    waited_ms: u64,
2347    /// The door's status message.
2348    message: String,
2349}
2350
2351/// One `call-member` call, written once it is accounted for: delivered to the calling task, or
2352/// left behind when that task ended.
2353///
2354/// A call that never started writes only this, with no `member_task_id`. Carries no URL and no
2355/// token.
2356#[derive(Serialize)]
2357struct MemberCallEvent {
2358    event_type: &'static str,
2359    event_id: String,
2360    parent_id: Option<String>,
2361    session_id: String,
2362    timestamp: u64,
2363    task_id: String,
2364    call_id: String,
2365    member: String,
2366    /// Absent, not null, for a call the callee never held.
2367    #[serde(skip_serializing_if = "Option::is_none")]
2368    member_task_id: Option<String>,
2369    /// `completed`, `failed`, `canceled`, `rejected`, `timed_out`, `unreachable`, `abandoned` or
2370    /// `no_answer`.
2371    status: String,
2372    duration_ms: u64,
2373    /// The callee's answer, its status message, or why the call ended.
2374    output: String,
2375    truncated: bool,
2376    /// Whether the calling task received `output`: as the tool result for a call that never
2377    /// started, as a continuation for one that did.
2378    delivered: bool,
2379    /// The members further down that gave the callee no answer, as its `GetTask` reported them.
2380    /// Absent when it reported none.
2381    #[serde(skip_serializing_if = "Vec::is_empty")]
2382    no_answer_below: Vec<NoAnswerBelow>,
2383}
2384
2385/// One member a callee reported as giving it no answer.
2386#[derive(Serialize)]
2387struct NoAnswerBelow {
2388    member: String,
2389    /// A `member_call` status other than `completed`.
2390    status: String,
2391}
2392
2393/// One delegation, written when it ends.
2394///
2395/// One line per call whatever happened, including a call the daemon refused: a delegation that was
2396/// asked for and not made is as much a fact of the run as one that produced an answer. When it is
2397/// written depends on how far the delegation got — a `delegate-task` call that started a child
2398/// writes it as that child's completion arrives, out of the child's own `completion.json`; one that
2399/// never started a child writes it within the call. A plan `capsule` step waits for its child's
2400/// answer, so it always writes this line within the step. Carries neither the task text nor the
2401/// child's answer — both are the agent's own conversation, which the `tool_call` line for the
2402/// same call already records under the session's `trace.capture` setting.
2403#[derive(Serialize)]
2404struct DelegationEvent {
2405    event_type: &'static str,
2406    event_id: String,
2407    parent_id: Option<String>,
2408    session_id: String,
2409    timestamp: u64,
2410    /// The sub-capsule the agent named, and the exact version it named.
2411    capsule: String,
2412    version: String,
2413    /// The `dlg_` id naming this delegation. `null` on a `refused` outcome, which made none.
2414    delegation_id: Option<String>,
2415    /// The child's own session id, so its trace is findable. `null` when no child ran.
2416    child_session_id: Option<String>,
2417    /// How long the child ran, for a delegation that started; how long the call took, for one that
2418    /// never started.
2419    duration_ms: u64,
2420    /// How the delegation ended. Which vocabulary it is drawn from depends on whether the
2421    /// delegating call waited for the answer itself:
2422    ///
2423    /// - A delegation whose outcome arrived later as a completion reads `ok`, `error`, `crashed`
2424    ///   or `terminated` from [`crate::delegation::DelegationStatus`], or `unknown` when the
2425    ///   completion left no readable file.
2426    /// - One the caller waited out reads `completed` or `timed_out` from
2427    ///   [`crate::delegation_plane::DelegationStatus`].
2428    /// - One that never started reads `failed` or `refused`, from that same enum, either way.
2429    outcome: String,
2430    /// `null` where nothing needs saying. Otherwise the completion's `detail`, or the same
2431    /// sentence the model was given.
2432    reason: Option<String>,
2433}
2434
2435impl DelegationEvent {
2436    /// The one place this record is built, so the two appenders that write it cannot drift into
2437    /// two shapes. Which surface launched the child is not an argument, because it is not a field.
2438    #[allow(clippy::too_many_arguments)]
2439    fn new(
2440        session_id: &str,
2441        session_event_id: &str,
2442        capsule: &str,
2443        version: &str,
2444        delegation_id: Option<String>,
2445        child_session_id: Option<String>,
2446        duration_ms: u64,
2447        outcome: &str,
2448        reason: Option<String>,
2449    ) -> Self {
2450        Self {
2451            event_type: "delegation",
2452            event_id: new_event_id(),
2453            parent_id: Some(session_event_id.to_string()),
2454            session_id: session_id.to_string(),
2455            timestamp: timestamp_ms(),
2456            capsule: capsule.to_string(),
2457            version: version.to_string(),
2458            delegation_id,
2459            child_session_id,
2460            duration_ms,
2461            outcome: outcome.to_string(),
2462            reason,
2463        }
2464    }
2465}
2466
2467// ── TraceWriter impl ─────────────────────────────────────────────────────────
2468
2469impl TraceWriter {
2470    #[allow(clippy::too_many_arguments)]
2471    pub(crate) async fn open(
2472        records_root: &WorkdirRoot,
2473        session_id: String,
2474        capsule_name: String,
2475        capsule_version: String,
2476        model: String,
2477        capabilities: Vec<String>,
2478        effective_grants: ScopeReport,
2479        capture: TraceCapture,
2480        system_prompt: Option<String>,
2481        system_prompt_overridden: bool,
2482        resumed_from: Option<String>,
2483        context_id: Option<String>,
2484        spawned_by: Option<String>,
2485        spawn_delegation_id: Option<String>,
2486    ) -> std::io::Result<Self> {
2487        let file = File::from_std(records_root.open_append(TRACE_FILE_NAME, DEFAULT_FILE_MODE)?);
2488        // `"cli"` wins over `"none"` when the override cleared the prompt: the operator passed
2489        // `--system-prompt ""`, which is a decision worth recording, not an absence.
2490        let system_prompt_source = match (system_prompt_overridden, system_prompt.is_some()) {
2491            (true, _) => "cli",
2492            (false, true) => "manifest",
2493            (false, false) => "none",
2494        };
2495        let system_prompt_sha256 = system_prompt
2496            .as_ref()
2497            .map(|prompt| murmur_artifact::sha256_hex(prompt.as_bytes()));
2498        let blobs = BlobStore::new(records_root);
2499        // The prompt the operator wrote, kept under the hash `session_start` already carried.
2500        // Written here rather than at `session_start` because a session writes that frame once
2501        // per task and the blob is the same one every time.
2502        if let (true, Some(prompt), Some(sha)) = (
2503            capture.captures_content(),
2504            system_prompt.as_ref(),
2505            system_prompt_sha256.as_deref(),
2506        ) {
2507            blobs.put(sha, prompt.as_bytes()).await?;
2508        }
2509        Ok(Self {
2510            writer: BufWriter::new(file),
2511            session_id,
2512            capsule_name,
2513            capsule_version,
2514            model,
2515            capabilities,
2516            effective_grants,
2517            capture,
2518            blobs,
2519            system_prompt_source,
2520            system_prompt_sha256,
2521            credential_source: "none",
2522            gateways: Vec::new(),
2523            control: None,
2524            inference_choices: Vec::new(),
2525            tool_refresh: None,
2526            runtime_artifacts: Vec::new(),
2527            host_facts: Vec::new(),
2528            instruction_files: None,
2529            resumed_from,
2530            context_id,
2531            spawned_by,
2532            spawn_delegation_id,
2533            formation_id: None,
2534            formation_member: None,
2535            session_start_time: Instant::now(),
2536            session_event_id: new_event_id(),
2537            session_started: false,
2538            session_ended: false,
2539            task_event_id: None,
2540            turn_event_id: None,
2541            total_turns: 0,
2542            total_input_tokens: 0,
2543            total_output_tokens: 0,
2544            total_tool_calls: 0,
2545            total_shell_calls: 0,
2546            task_turns: 0,
2547            task_input_tokens: 0,
2548            task_output_tokens: 0,
2549            task_tool_calls: 0,
2550            task_shell_calls: 0,
2551            task_agent_turns: 0,
2552            task_hook_inferences: 0,
2553            task_capped_turns: 0,
2554            task_start_instant: None,
2555            active_task_id: None,
2556            task_failures_written: 0,
2557            last_task_failure: None,
2558            max_session_tokens: None,
2559            machine_tokens_per_day: None,
2560            session_status: None,
2561            call_deadlines: None,
2562        })
2563    }
2564
2565    /// The session's [`crate::limits::CallDeadlines`], which this writer drains into one
2566    /// `call_deadline` line per firing ahead of each line it writes, `session_end` included.
2567    pub(crate) fn set_call_deadlines(&mut self, deadlines: crate::limits::CallDeadlines) {
2568        self.call_deadlines = Some(deadlines);
2569    }
2570
2571    /// The session's standing, which every later `inference`, `task_start` and `task_end` write
2572    /// feeds and whose deadline every later `session_start` records.
2573    pub(crate) fn set_session_status(
2574        &mut self,
2575        status: std::sync::Arc<crate::session_status::SessionStatus>,
2576    ) {
2577        self.session_status = Some(status);
2578    }
2579
2580    /// The spend ceilings this session runs under, written into every later `session_start`.
2581    pub(crate) fn set_spend_ceilings(
2582        &mut self,
2583        max_session_tokens: Option<u64>,
2584        machine_tokens_per_day: Option<u64>,
2585    ) {
2586        self.max_session_tokens = max_session_tokens;
2587        self.machine_tokens_per_day = machine_tokens_per_day;
2588    }
2589
2590    /// The session node of this writer's event tree — the `event_id` its `session_start` will
2591    /// carry, available before that line is written so [`ResourceTraceAppender`] can be opened
2592    /// against the same node.
2593    pub(crate) fn session_event_id(&self) -> &str {
2594        &self.session_event_id
2595    }
2596
2597    /// Whether this session's `inference` records carry content hashes — everything but
2598    /// `trace.capture: none`. The agent loop asks before splitting a payload into a
2599    /// [`WireCapture`], so `none` costs no serialization at all.
2600    pub(crate) fn captures_wire(&self) -> bool {
2601        self.capture.captures_hashes()
2602    }
2603
2604    /// Parent for a launch-scoped event: the session node, or `None` on a writer that has not
2605    /// written `session_start`. A trace line must never name a parent that has no line behind
2606    /// it.
2607    fn session_parent(&self) -> Option<String> {
2608        self.session_started.then(|| self.session_event_id.clone())
2609    }
2610
2611    /// Parent for a task-scoped event: the task node, falling back to the session node when no
2612    /// task is active.
2613    fn task_parent(&self) -> Option<String> {
2614        self.task_event_id.clone().or_else(|| self.session_parent())
2615    }
2616
2617    /// Parent for a turn-scoped event: the turn node, falling back to the task node and then to
2618    /// the session node. The fallbacks are what keeps a tool call written outside any turn — a
2619    /// hook's, or the process transport's, before its first `inference` — attached to the tree.
2620    fn turn_parent(&self) -> Option<String> {
2621        self.turn_event_id.clone().or_else(|| self.task_parent())
2622    }
2623
2624    /// Records where the inference credential came from, as
2625    /// [`crate::gateway_credential::CredentialSource::trace_name`] names it. `"none"` until set.
2626    pub(crate) fn set_credential_source(&mut self, source: &'static str) {
2627        self.credential_source = source;
2628    }
2629
2630    /// Records the session's credential gateways for `session_start.gateways`.
2631    pub(crate) fn set_gateways(&mut self, gateways: Vec<SessionGateway>) {
2632        self.gateways = gateways;
2633    }
2634
2635    /// Records what `control:` declares for `session_start.control`.
2636    pub(crate) fn set_control(&mut self, control: Option<SessionControl>) {
2637        self.control = control;
2638    }
2639
2640    /// Records the driver choices for `session_start.inference_choices`. Left empty, the key is
2641    /// not written.
2642    pub(crate) fn set_inference_choices(&mut self, choices: Vec<SessionInferenceChoice>) {
2643        self.inference_choices = choices;
2644    }
2645
2646    /// Records the runtime-origin entries of `installed` for `session_start.runtime_artifacts`,
2647    /// in `installed`'s order. Operator-declared entries are left out.
2648    pub(crate) fn set_runtime_artifacts(&mut self, installed: &[InstalledArtifactSummary]) {
2649        self.runtime_artifacts = installed
2650            .iter()
2651            .filter_map(|artifact| match &artifact.origin {
2652                LockOrigin::Runtime { session } => Some(RuntimeArtifact {
2653                    name: artifact.name.clone(),
2654                    version: artifact.version.clone(),
2655                    origin: artifact.origin.as_str(),
2656                    session: session.clone(),
2657                    trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
2658                }),
2659                LockOrigin::Operator => None,
2660            })
2661            .collect();
2662    }
2663
2664    /// Records `uses`, in order, for `session_start.host_facts`.
2665    pub(crate) fn set_host_facts(&mut self, uses: &[crate::host_facts::HostFactsUse]) {
2666        self.host_facts = uses.iter().map(HostFactsRecord::of).collect();
2667    }
2668
2669    /// Records that this inference call is the first to use a setting value a controller set:
2670    /// `turn` is the call's zero-based turn, the same number its `inference` line carries, and
2671    /// `change_id` the `event_id` of the `control_change` that set `value`.
2672    ///
2673    /// Written before the call is sent, so it precedes the turn's `inference` line and hangs off
2674    /// the task.
2675    pub(crate) async fn write_control_applied(
2676        &mut self,
2677        turn: u32,
2678        name: &str,
2679        value: Value,
2680        change_id: &str,
2681    ) -> std::io::Result<()> {
2682        let event = ControlAppliedEvent {
2683            event_type: "control_applied",
2684            event_id: new_event_id(),
2685            parent_id: self.task_parent(),
2686            session_id: self.session_id.clone(),
2687            timestamp: timestamp_ms(),
2688            turn,
2689            task_id: self.active_task_id.clone(),
2690            name,
2691            value,
2692            change_id,
2693        };
2694        self.write_event(&event).await
2695    }
2696
2697    /// Records the formation this session is a member of for `session_start.formation_id`.
2698    pub(crate) fn set_formation_id(&mut self, formation_id: Option<&FormationId>) {
2699        self.formation_id = formation_id.cloned();
2700    }
2701
2702    /// Records `session_start.formation_member` and `session_start.formation_callees` for a
2703    /// session its formation launcher handed credentials to.
2704    pub(crate) fn set_formation_member(
2705        &mut self,
2706        member: Option<&crate::formation_credentials::FormationMember>,
2707    ) {
2708        self.formation_member = member.map(|member| {
2709            (
2710                member.name().to_string(),
2711                member.callees().map(str::to_string).collect(),
2712            )
2713        });
2714    }
2715
2716    /// Records what staging read for `inference.instruction_files` for
2717    /// `session_start.instruction_files`; `None` writes `null`.
2718    pub(crate) fn set_instruction_files(
2719        &mut self,
2720        files: Option<&crate::instruction_files::InstructionFiles>,
2721    ) {
2722        self.instruction_files =
2723            files.map(crate::instruction_files::InstructionFiles::trace_records);
2724    }
2725
2726    /// Records `inference.tool_refresh` for `session_start.tool_refresh`: the wire name under
2727    /// `transport: http`, `None` under `transport: process`.
2728    pub(crate) fn set_tool_refresh(&mut self, tool_refresh: Option<&'static str>) {
2729        self.tool_refresh = tool_refresh;
2730    }
2731
2732    /// Records that the tool array the http agent loop sends changed before this call:
2733    /// `trigger` is why (`"compaction"`, `"agent"` or `"immediate"`), `added` and `removed` the
2734    /// sorted names that entered and left it, and `tools` every name it now offers, in array
2735    /// order.
2736    ///
2737    /// Written before the call is sent, so it precedes the turn's `inference` line and hangs off
2738    /// the task, as `control_applied` does.
2739    pub(crate) async fn write_tools_refreshed(
2740        &mut self,
2741        turn: u32,
2742        trigger: &str,
2743        added: &[String],
2744        removed: &[String],
2745        tools: &[String],
2746    ) -> std::io::Result<()> {
2747        let event = ToolsRefreshedEvent {
2748            event_type: "tools_refreshed",
2749            event_id: new_event_id(),
2750            parent_id: self.task_parent(),
2751            session_id: self.session_id.clone(),
2752            timestamp: timestamp_ms(),
2753            turn,
2754            task_id: self.active_task_id.clone(),
2755            trigger,
2756            added,
2757            removed,
2758            tools,
2759        };
2760        self.write_event(&event).await
2761    }
2762
2763    pub(crate) async fn write_session_start(
2764        &mut self,
2765        max_turns: Option<u32>,
2766        model_limits: Option<crate::model_limits::ResolvedModelLimits>,
2767        tools_declared: Vec<String>,
2768    ) -> std::io::Result<()> {
2769        let event = SessionStartEvent {
2770            event_type: "session_start",
2771            event_id: self.session_event_id.clone(),
2772            parent_id: None,
2773            session_id: self.session_id.clone(),
2774            timestamp: timestamp_ms(),
2775            capsule_name: self.capsule_name.clone(),
2776            capsule_version: self.capsule_version.clone(),
2777            model: self.model.clone(),
2778            max_turns,
2779            model_limits,
2780            max_session_tokens: self.max_session_tokens,
2781            machine_tokens_per_day: self.machine_tokens_per_day,
2782            deadline: self.session_status.as_ref().and_then(|status| {
2783                status.deadline().map(|deadline| SessionStartDeadline {
2784                    at: deadline.at_rfc3339(),
2785                    secs: deadline.secs(),
2786                    notice_secs: status.notice_secs(),
2787                    source: deadline.source().as_str(),
2788                })
2789            }),
2790            capabilities: self.capabilities.clone(),
2791            tools_declared,
2792            containment_declared: self.effective_grants.declared_containment,
2793            containment_achieved: self.effective_grants.achieved_containment,
2794            workdir_exec: self.effective_grants.workdir_exec,
2795            userns_grant: self.effective_grants.userns_grant,
2796            effective_grants: self.effective_grants.clone(),
2797            system_prompt_source: self.system_prompt_source,
2798            system_prompt_sha256: self.system_prompt_sha256.clone(),
2799            credential_source: self.credential_source,
2800            gateways: self.gateways.clone(),
2801            control: self.control.clone(),
2802            inference_choices: self.inference_choices.clone(),
2803            tool_refresh: self.tool_refresh,
2804            runtime_artifacts: self.runtime_artifacts.clone(),
2805            host_facts: self.host_facts.clone(),
2806            instruction_files: self.instruction_files.clone(),
2807            resumed_from: self.resumed_from.clone(),
2808            context_id: self.context_id.clone(),
2809            spawned_by: self.spawned_by.clone(),
2810            delegation_id: self.spawn_delegation_id.clone(),
2811            formation_id: self.formation_id.clone(),
2812            formation_member: self.formation_member.as_ref().map(|(name, _)| name.clone()),
2813            formation_callees: self
2814                .formation_member
2815                .as_ref()
2816                .map(|(_, callees)| callees.clone()),
2817        };
2818        self.write_event(&event).await?;
2819        self.session_started = true;
2820        // A firing from before the session frame existed — an `on-stage` hook's — hangs off it.
2821        self.write_call_deadlines().await
2822    }
2823
2824    /// `input_tokens`/`output_tokens` are what the session and task totals accumulate: the
2825    /// runtime's own tiktoken estimates under `transport: http`, and the counts the harness's
2826    /// driver reported under `transport: process`, where the runtime measures nothing itself.
2827    /// `None` is a turn nothing counted, which only a process driver reporting no usage
2828    /// produces, and it adds nothing to the totals.
2829    ///
2830    /// `usage` is written verbatim beside them and accumulated into nothing. Under `http` it is
2831    /// the provider's own report for the same call; under `process` it carries only the cache and
2832    /// thinking counts, because the harness's input and output are already the two above.
2833    ///
2834    /// `settings` is how an `http` call was sent, written as `max_output_tokens` and
2835    /// `driver_config`; `None` under `process`, which writes neither key.
2836    #[allow(clippy::too_many_arguments)]
2837    pub(crate) async fn write_inference(
2838        &mut self,
2839        turn: u32,
2840        input_tokens: Option<u64>,
2841        output_tokens: Option<u64>,
2842        decision: String,
2843        stop_reason: Option<&str>,
2844        tool_name: Option<String>,
2845        origin: Option<&InferenceOrigin>,
2846        usage: Option<&DriverUsage>,
2847        message_ids: Vec<String>,
2848        wire: Option<&WireCapture>,
2849        settings: Option<&InferenceCallSettings>,
2850    ) -> std::io::Result<()> {
2851        self.write_inference_event(InferenceLine {
2852            turn,
2853            input_tokens,
2854            output_tokens,
2855            decision,
2856            stop_reason,
2857            tool_name,
2858            origin,
2859            usage,
2860            message_ids,
2861            wire,
2862            choice: None,
2863            failure: None,
2864            counted: true,
2865            settings,
2866            retry: RetryNote::default(),
2867            gateway: None,
2868        })
2869        .await
2870    }
2871
2872    /// [`Self::write_inference`] for a hook's `run-inference` that succeeded, with the attempts
2873    /// its retry loop took.
2874    #[allow(clippy::too_many_arguments)]
2875    pub(crate) async fn write_hook_inference(
2876        &mut self,
2877        turn: u32,
2878        input_tokens: Option<u64>,
2879        output_tokens: Option<u64>,
2880        decision: String,
2881        origin: &InferenceOrigin,
2882        usage: Option<&DriverUsage>,
2883        settings: Option<&InferenceCallSettings>,
2884        retry: RetryNote,
2885        gateway: Option<GatewayOffsets>,
2886    ) -> std::io::Result<()> {
2887        self.write_inference_event(InferenceLine {
2888            turn,
2889            input_tokens,
2890            output_tokens,
2891            decision,
2892            stop_reason: None,
2893            tool_name: None,
2894            origin: Some(origin),
2895            usage,
2896            message_ids: Vec::new(),
2897            wire: None,
2898            choice: None,
2899            failure: None,
2900            counted: true,
2901            settings,
2902            retry,
2903            gateway,
2904        })
2905        .await
2906    }
2907
2908    /// [`Self::write_inference`] for an agent-loop turn, naming the driver choice that served it
2909    /// as `driver_choice` and `model`. `choice` is `None` on a capsule with no alternates, whose
2910    /// line is exactly what [`Self::write_inference`] writes.
2911    #[allow(clippy::too_many_arguments)]
2912    pub(crate) async fn write_turn_inference(
2913        &mut self,
2914        turn: u32,
2915        input_tokens: Option<u64>,
2916        output_tokens: Option<u64>,
2917        decision: String,
2918        stop_reason: Option<&str>,
2919        tool_name: Option<String>,
2920        usage: Option<&DriverUsage>,
2921        message_ids: Vec<String>,
2922        wire: Option<&WireCapture>,
2923        choice: Option<&crate::driver_choice::DriverChoice>,
2924        settings: Option<&InferenceCallSettings>,
2925        retry: RetryNote,
2926        gateway: Option<GatewayOffsets>,
2927    ) -> std::io::Result<()> {
2928        if stop_reason == Some("max_tokens") {
2929            self.task_capped_turns = self.task_capped_turns.saturating_add(1);
2930        }
2931        self.write_inference_event(InferenceLine {
2932            turn,
2933            input_tokens,
2934            output_tokens,
2935            decision,
2936            stop_reason,
2937            tool_name,
2938            origin: None,
2939            usage,
2940            message_ids,
2941            wire,
2942            choice,
2943            failure: None,
2944            counted: true,
2945            settings,
2946            retry,
2947            gateway,
2948        })
2949        .await
2950    }
2951
2952    /// Record a model call that failed: `decision` and `stop_reason` `"error"`, the failure's
2953    /// `error_code`, `error` (capped at [`MAX_TASK_FAILURE_REASON_BYTES`]) and `provider_status`.
2954    ///
2955    /// Never hashes or stores a wire body, whatever `trace.capture` is. Writes `input_tokens` and
2956    /// `output_tokens` only from `failed.spent`, and advances the turn and token counters only
2957    /// when `spent` is `Some`, by exactly its counts. Parents like any other `inference`: an
2958    /// agent-loop record becomes the turn node, a hook's hangs off the turn.
2959    pub(crate) async fn write_failed_inference(
2960        &mut self,
2961        failed: FailedInference<'_>,
2962    ) -> std::io::Result<()> {
2963        let error =
2964            crate::agent::truncate_on_char_boundary(failed.error, MAX_TASK_FAILURE_REASON_BYTES)
2965                .to_string();
2966        let (input_tokens, output_tokens) = failed.spent.unwrap_or((None, None));
2967        self.write_inference_event(InferenceLine {
2968            turn: failed.turn,
2969            input_tokens,
2970            output_tokens,
2971            decision: "error".to_string(),
2972            stop_reason: Some("error"),
2973            tool_name: failed.tool_name,
2974            origin: failed.origin,
2975            usage: failed.usage,
2976            message_ids: failed.message_ids,
2977            wire: None,
2978            choice: failed.choice,
2979            failure: Some((failed.error_code, error, failed.provider_status)),
2980            counted: failed.spent.is_some(),
2981            settings: failed.settings,
2982            retry: failed.retry,
2983            gateway: failed.gateway,
2984        })
2985        .await
2986    }
2987
2988    async fn write_inference_event(&mut self, line: InferenceLine<'_>) -> std::io::Result<()> {
2989        let InferenceLine {
2990            turn,
2991            input_tokens,
2992            output_tokens,
2993            decision,
2994            stop_reason,
2995            tool_name,
2996            origin,
2997            usage,
2998            message_ids,
2999            wire,
3000            choice,
3001            failure,
3002            counted,
3003            settings,
3004            retry,
3005            gateway,
3006        } = line;
3007        let (error_code, error, provider_status) = match failure {
3008            Some((code, error, status)) => (Some(code.to_string()), Some(error), status),
3009            None => (None, None, None),
3010        };
3011        // Drained under the previous turn, before this line becomes the turn node.
3012        self.write_call_deadlines().await?;
3013        let event_id = new_event_id();
3014        // The agent loop's own inference *is* the turn node — there is no separate turn line —
3015        // so it hangs off the task and everything the turn goes on to produce hangs off it. A
3016        // hook's `run-inference` (`origin: Some(_)`) is one of those products, not a new turn.
3017        let parent_id = if origin.is_none() {
3018            let parent = self.task_parent();
3019            self.turn_event_id = Some(event_id.clone());
3020            parent
3021        } else {
3022            self.turn_parent()
3023        };
3024        let (system_sha, tools_sha, response_sha, message_shas) =
3025            match wire.filter(|_| self.capture.captures_hashes()) {
3026                Some(wire) => self.record_wire(wire).await?,
3027                None => (None, None, None, Vec::new()),
3028            };
3029        let event = InferenceEvent {
3030            event_type: "inference",
3031            event_id,
3032            parent_id,
3033            session_id: self.session_id.clone(),
3034            timestamp: timestamp_ms(),
3035            turn,
3036            task_id: self.active_task_id.clone(),
3037            input_tokens,
3038            output_tokens,
3039            decision,
3040            stop_reason: stop_reason.map(str::to_string),
3041            tool_name,
3042            input_tokens_actual: usage.and_then(|u| u.input_tokens),
3043            output_tokens_actual: usage.and_then(|u| u.output_tokens),
3044            cached_tokens: usage.and_then(|u| u.cached_tokens),
3045            cache_write_tokens: usage.and_then(|u| u.cache_write_tokens),
3046            thinking_tokens: usage.and_then(|u| u.thinking_tokens),
3047            origin: origin.map(|o| o.source.clone()),
3048            driver_choice: choice.map(|choice| choice.name.clone()),
3049            model: origin
3050                .map(|o| o.model.clone())
3051                .or_else(|| choice.map(|choice| choice.model.clone())),
3052            max_output_tokens: settings.map(|settings| settings.max_output_tokens),
3053            input_budget: settings.map(|settings| settings.input_budget),
3054            driver_config: settings.map(|settings| settings.driver_config.clone()),
3055            message_ids,
3056            system_sha,
3057            tools_sha,
3058            response_sha,
3059            message_shas,
3060            error_code,
3061            error,
3062            provider_status,
3063            gateway_reached_ms: gateway.map(|gateway| gateway.reached_ms),
3064            upstream_responded_ms: gateway.map(|gateway| gateway.responded_ms),
3065            attempts: retry.attempts,
3066            retry_stopped: retry.stopped.map(RetryStop::wire_name),
3067        };
3068        self.write_event(&event).await?;
3069        if origin.is_none() {
3070            self.task_agent_turns = self.task_agent_turns.saturating_add(1);
3071        }
3072        if counted {
3073            // A turn nothing counted contributes nothing to the totals. The totals are plain
3074            // `u64` because a session's spend is a sum, and the sum of no measurements is zero;
3075            // the absent/zero distinction lives on the record itself.
3076            let (input, output) = (input_tokens.unwrap_or(0), output_tokens.unwrap_or(0));
3077            self.total_turns = self.total_turns.saturating_add(1);
3078            self.total_input_tokens = self.total_input_tokens.saturating_add(input);
3079            self.total_output_tokens = self.total_output_tokens.saturating_add(output);
3080            self.task_turns = self.task_turns.saturating_add(1);
3081            if origin.is_some() {
3082                self.task_hook_inferences = self.task_hook_inferences.saturating_add(1);
3083            }
3084            self.task_input_tokens = self.task_input_tokens.saturating_add(input);
3085            self.task_output_tokens = self.task_output_tokens.saturating_add(output);
3086        }
3087        // Every record, counted or not: the provider may have reported a call the turn and token
3088        // totals do not count.
3089        if let Some(status) = &self.session_status {
3090            status.record_inference(self.task_turns, input_tokens, output_tokens, usage);
3091        }
3092        Ok(())
3093    }
3094
3095    /// Hash the four bodies of one request and, under [`TraceCapture::Content`], store each
3096    /// behind its hash.
3097    ///
3098    /// The hash is computed in every capture mode that reaches here; only the blob write is
3099    /// gated, so `meta` and `content` name the same bytes and differ solely in whether those
3100    /// bytes are also on disk.
3101    async fn record_wire(
3102        &mut self,
3103        wire: &WireCapture,
3104    ) -> std::io::Result<(Option<String>, Option<String>, Option<String>, Vec<String>)> {
3105        let system_sha = murmur_artifact::sha256_hex(&wire.system);
3106        let tools_sha = murmur_artifact::sha256_hex(&wire.tools);
3107        let response_sha = murmur_artifact::sha256_hex(&wire.response);
3108        let message_shas: Vec<String> = wire
3109            .messages
3110            .iter()
3111            .map(|message| murmur_artifact::sha256_hex(message))
3112            .collect();
3113
3114        if self.capture.captures_content() {
3115            // Verbatim, unredacted. `write_tool_call` redacts peer handles out of its own summary
3116            // fields; a blob is not a summary, and redacting one would make its hash name bytes
3117            // no file holds — destroying the only property this store exists to provide.
3118            self.blobs.put(&system_sha, &wire.system).await?;
3119            self.blobs.put(&tools_sha, &wire.tools).await?;
3120            self.blobs.put(&response_sha, &wire.response).await?;
3121            for (sha, body) in message_shas.iter().zip(&wire.messages) {
3122                self.blobs.put(sha, body).await?;
3123            }
3124        }
3125
3126        Ok((
3127            Some(system_sha),
3128            Some(tools_sha),
3129            Some(response_sha),
3130            message_shas,
3131        ))
3132    }
3133
3134    #[allow(clippy::too_many_arguments)]
3135    pub(crate) async fn write_tool_call(
3136        &mut self,
3137        turn: u32,
3138        tool_name: String,
3139        tool_call_id: Option<String>,
3140        input: Value,
3141        input_bytes: u64,
3142        output: &str,
3143        output_bytes: u64,
3144        duration_ms: u64,
3145        status: String,
3146        state_effect: Option<String>,
3147        resource_id: Option<String>,
3148        error: Option<&str>,
3149    ) -> std::io::Result<()> {
3150        self.write_tool_call_with_attachments(
3151            turn,
3152            tool_name,
3153            tool_call_id,
3154            input,
3155            input_bytes,
3156            output,
3157            output_bytes,
3158            duration_ms,
3159            status,
3160            state_effect,
3161            resource_id,
3162            error,
3163            Vec::new(),
3164        )
3165        .await
3166    }
3167
3168    /// [`Self::write_tool_call`] for a call that returned images: `attachments` becomes the
3169    /// record's `attachments` field, which an empty list omits.
3170    #[allow(clippy::too_many_arguments)]
3171    pub(crate) async fn write_tool_call_with_attachments(
3172        &mut self,
3173        turn: u32,
3174        tool_name: String,
3175        tool_call_id: Option<String>,
3176        mut input: Value,
3177        input_bytes: u64,
3178        output: &str,
3179        output_bytes: u64,
3180        duration_ms: u64,
3181        status: String,
3182        state_effect: Option<String>,
3183        resource_id: Option<String>,
3184        error: Option<&str>,
3185        attachments: Vec<AttachmentRecord>,
3186    ) -> std::io::Result<()> {
3187        // A peer handle reaches this event by an ordinary route — it is an argument the model
3188        // passed to `fetch-peer-file`, and every call's arguments are recorded. It is also a
3189        // credential, and the trace is durable, so the two must not meet: the `handle_id` goes in
3190        // instead, which is what correlates the record with the mint and the redeem anyway.
3191        crate::peer_handoff::redact_handles_in_json(&mut input);
3192        let event = ToolCallEvent {
3193            event_type: "tool_call",
3194            event_id: new_event_id(),
3195            parent_id: self.turn_parent(),
3196            session_id: self.session_id.clone(),
3197            timestamp: timestamp_ms(),
3198            turn,
3199            task_id: self.active_task_id.clone(),
3200            tool_name,
3201            // An absent or empty provider id records as `null`: `""` would read as an id the
3202            // provider actually issued.
3203            tool_call_id: tool_call_id.filter(|id| !id.is_empty()),
3204            input,
3205            input_bytes,
3206            output: self
3207                .capture
3208                .captures_content()
3209                .then(|| crate::peer_handoff::redact_handle_tokens(output).into_owned()),
3210            output_bytes,
3211            duration_ms,
3212            status,
3213            error: error.map(|error| {
3214                let redacted = crate::peer_handoff::redact_handle_tokens(error);
3215                crate::agent::truncate_on_char_boundary(&redacted, MAX_TASK_FAILURE_REASON_BYTES)
3216                    .to_string()
3217            }),
3218            state_effect,
3219            resource_id,
3220            attachments,
3221        };
3222        self.write_event(&event).await?;
3223        self.total_tool_calls = self.total_tool_calls.saturating_add(1);
3224        self.task_tool_calls = self.task_tool_calls.saturating_add(1);
3225        Ok(())
3226    }
3227
3228    /// Records a skill invocation as a `skill_call` event. Skill calls are NOT counted in
3229    /// `total_tool_calls` — they appear in a separate `── Skill calls ──` section in `mur trace show`.
3230    ///
3231    /// `origin` is the skill's `murmur.lock` origin; the record's `trust` is derived from it
3232    /// through [`crate::origin::artifact_trust`], the same rule that decides the skill's fence.
3233    pub(crate) async fn write_skill_call(
3234        &mut self,
3235        turn: u32,
3236        skill_name: String,
3237        origin: &LockOrigin,
3238        output_bytes: u64,
3239        duration_ms: u64,
3240        status: String,
3241    ) -> std::io::Result<()> {
3242        let event = SkillCallEvent {
3243            event_type: "skill_call",
3244            event_id: new_event_id(),
3245            parent_id: self.turn_parent(),
3246            session_id: self.session_id.clone(),
3247            timestamp: timestamp_ms(),
3248            turn,
3249            task_id: self.active_task_id.clone(),
3250            skill_name,
3251            output_bytes,
3252            duration_ms,
3253            status,
3254            origin: origin.as_str(),
3255            trust: crate::origin::artifact_trust(origin).as_str(),
3256        };
3257        self.write_event(&event).await
3258        // Intentionally does not increment total_tool_calls or task_tool_calls.
3259    }
3260
3261    // One positional parameter per JSONL column, as every other `write_*` here does; the
3262    // sibling `write_tool_call` carries the same allowance.
3263    #[allow(clippy::too_many_arguments)]
3264    pub(crate) async fn write_shell(
3265        &mut self,
3266        turn: u32,
3267        binary: String,
3268        command: String,
3269        exit_code: i32,
3270        stdout_bytes: u64,
3271        stderr_bytes: u64,
3272        duration_ms: u64,
3273        resource_limit: Option<String>,
3274    ) -> std::io::Result<()> {
3275        let event = ShellEvent {
3276            event_type: "shell",
3277            event_id: new_event_id(),
3278            parent_id: self.turn_parent(),
3279            session_id: self.session_id.clone(),
3280            timestamp: timestamp_ms(),
3281            turn,
3282            task_id: self.active_task_id.clone(),
3283            binary,
3284            command,
3285            exit_code,
3286            stdout_bytes,
3287            stderr_bytes,
3288            duration_ms,
3289            resource_limit,
3290        };
3291        self.write_event(&event).await?;
3292        self.total_shell_calls = self.total_shell_calls.saturating_add(1);
3293        self.task_shell_calls = self.task_shell_calls.saturating_add(1);
3294        Ok(())
3295    }
3296
3297    /// A command demoted to the background, written from the agent loop at the moment of
3298    /// demotion.
3299    ///
3300    /// Counts as this session's shell call, exactly as a foreground one does — the pair with
3301    /// [`Self::write_shell_completed`], which deliberately does not count, so `mur trace show`
3302    /// reports each shell command once whichever way it ran.
3303    pub(crate) async fn write_shell_detached(
3304        &mut self,
3305        turn: u32,
3306        work_id: &str,
3307        binary: &str,
3308        command: &str,
3309        grace_ms: Option<u64>,
3310    ) -> std::io::Result<()> {
3311        let event = ShellDetachedEvent {
3312            event_type: "shell_detached",
3313            event_id: new_event_id(),
3314            parent_id: self.turn_parent(),
3315            session_id: self.session_id.clone(),
3316            timestamp: timestamp_ms(),
3317            turn,
3318            task_id: self.active_task_id.clone(),
3319            work_id: work_id.to_string(),
3320            binary: binary.to_string(),
3321            command: command.to_string(),
3322            grace_ms,
3323        };
3324        self.write_event(&event).await?;
3325        self.total_shell_calls = self.total_shell_calls.saturating_add(1);
3326        self.task_shell_calls = self.task_shell_calls.saturating_add(1);
3327        Ok(())
3328    }
3329
3330    /// A detached command's completion queued as the `completion`-origin task
3331    /// `completion_task_id`, written by the task loop as it enqueues it, so the two records join.
3332    pub(crate) async fn write_shell_completed(
3333        &mut self,
3334        completion: &crate::detached::DetachedCompletion,
3335        completion_task_id: &str,
3336    ) -> std::io::Result<()> {
3337        let event = ShellCompletedEvent::new(
3338            self.session_id.clone(),
3339            self.session_parent(),
3340            completion,
3341            crate::detached::Delivery::Task,
3342            Some(completion_task_id),
3343        );
3344        self.write_event(&event).await
3345    }
3346
3347    /// A detached command the session ended without waiting for.
3348    ///
3349    /// `recovered` carries the exit code, the output path and that file's byte count for a command
3350    /// that finished during teardown, and is `None` for one still running. The three travel as one
3351    /// argument because they exist together: a command with an exit code has a written log, and
3352    /// one without has neither.
3353    pub(crate) async fn write_shell_abandoned(
3354        &mut self,
3355        work_id: &str,
3356        binary: &str,
3357        command: &str,
3358        running_ms: u64,
3359        recovered: Option<(i32, &str, u64)>,
3360    ) -> std::io::Result<()> {
3361        let event = ShellAbandonedEvent {
3362            event_type: "shell_abandoned",
3363            event_id: new_event_id(),
3364            parent_id: self.session_parent(),
3365            session_id: self.session_id.clone(),
3366            timestamp: timestamp_ms(),
3367            work_id: work_id.to_string(),
3368            binary: binary.to_string(),
3369            command: command.to_string(),
3370            running_ms,
3371            exit_code: recovered.map(|(exit_code, _, _)| exit_code),
3372            output_path: recovered.map(|(_, path, _)| path.to_string()),
3373            output_bytes: recovered.map(|(_, _, bytes)| bytes),
3374        };
3375        self.write_event(&event).await
3376    }
3377
3378    /// A demotion the trace could not record, written where the failure was seen.
3379    pub(crate) async fn write_shell_detach_unrecorded(
3380        &mut self,
3381        turn: u32,
3382        work_id: &str,
3383        binary: &str,
3384        reason: &str,
3385    ) -> std::io::Result<()> {
3386        let event = ShellDetachUnrecordedEvent {
3387            event_type: "shell_detach_unrecorded",
3388            event_id: new_event_id(),
3389            parent_id: self.turn_parent(),
3390            session_id: self.session_id.clone(),
3391            timestamp: timestamp_ms(),
3392            turn,
3393            task_id: self.active_task_id.clone(),
3394            work_id: work_id.to_string(),
3395            binary: binary.to_string(),
3396            reason: reason.to_string(),
3397        };
3398        self.write_event(&event).await
3399    }
3400
3401    /// Records how one tool call's safety checks ended. Parented to the turn.
3402    pub(crate) async fn write_safety_check(
3403        &mut self,
3404        turn: u32,
3405        tool_call_id: &str,
3406        tool: &str,
3407        checks: &serde_json::Value,
3408        outcome: &'static str,
3409    ) -> std::io::Result<()> {
3410        let event = SafetyCheckEvent {
3411            event_type: "safety_check",
3412            event_id: new_event_id(),
3413            parent_id: self.turn_parent(),
3414            session_id: self.session_id.clone(),
3415            timestamp: timestamp_ms(),
3416            turn,
3417            task_id: self.active_task_id.clone(),
3418            tool_call_id: tool_call_id.to_string(),
3419            tool: tool.to_string(),
3420            checks: checks.clone(),
3421            outcome,
3422        };
3423        self.write_event(&event).await
3424    }
3425
3426    /// Records a task the door let in. `caller` is who the door let it in as and `caller_member`
3427    /// the roster name a formation token carried; a task with a member is recorded as the
3428    /// member's, whatever credential the token was minted for. The message text is hashed under
3429    /// `meta` and `content` and stored as a blob under `content`; the JSONL never holds it.
3430    #[allow(clippy::too_many_arguments)]
3431    pub(crate) async fn write_a2a_task_received(
3432        &mut self,
3433        task_id: &str,
3434        context_id: &str,
3435        message_id: &str,
3436        traceparent_from_caller: Option<&str>,
3437        caller: &crate::identity::TaskCaller,
3438        caller_member: Option<&str>,
3439        message_text: &str,
3440    ) -> std::io::Result<()> {
3441        use crate::identity::TaskCaller;
3442        let (received_from, caller_credential) = match (caller_member, caller) {
3443            (Some(_), _) => (RECEIVED_FROM_MEMBER, None),
3444            (None, TaskCaller::Operator) => (RECEIVED_FROM_OPERATOR, None),
3445            (None, TaskCaller::Public) => (RECEIVED_FROM_PUBLIC, None),
3446            (None, TaskCaller::Credential(name)) => {
3447                (RECEIVED_FROM_CREDENTIAL, Some(name.to_string()))
3448            }
3449        };
3450        let message_sha256 = if self.capture.captures_hashes() {
3451            let sha = murmur_artifact::sha256_hex(message_text.as_bytes());
3452            if self.capture.captures_content() {
3453                self.blobs.put(&sha, message_text.as_bytes()).await?;
3454            }
3455            Some(sha)
3456        } else {
3457            None
3458        };
3459        let event = A2aTaskReceivedEvent {
3460            event_type: "a2a_task_received",
3461            event_id: new_event_id(),
3462            parent_id: self.session_parent(),
3463            session_id: self.session_id.clone(),
3464            timestamp: timestamp_ms(),
3465            task_id: task_id.to_string(),
3466            context_id: context_id.to_string(),
3467            message_id: message_id.to_string(),
3468            traceparent_from_caller: traceparent_from_caller.map(str::to_string),
3469            caller: received_from,
3470            caller_member: caller_member.map(str::to_string),
3471            caller_credential,
3472            message_sha256,
3473        };
3474        self.write_event(&event).await
3475    }
3476
3477    pub(crate) async fn write_a2a_send(
3478        &mut self,
3479        peer_url: &str,
3480        message_id: &str,
3481        task_id: &str,
3482        context_id: &str,
3483        traceparent: Option<&str>,
3484        trust: TrustClass,
3485    ) -> std::io::Result<()> {
3486        let event = A2aSendEvent {
3487            event_type: "a2a_send",
3488            event_id: new_event_id(),
3489            parent_id: self.session_parent(),
3490            session_id: self.session_id.clone(),
3491            timestamp: timestamp_ms(),
3492            peer_url: peer_url.to_string(),
3493            message_id: message_id.to_string(),
3494            task_id: task_id.to_string(),
3495            context_id: context_id.to_string(),
3496            traceparent: traceparent.map(str::to_string),
3497            trust: trust.as_str().to_string(),
3498        };
3499        self.write_event(&event).await
3500    }
3501
3502    /// Records a successful pull of `pulled` (`manage.pull()` or `pull-artifact`), as the store
3503    /// summarised it after the lock was written. Parented to the session node.
3504    pub(crate) async fn write_artifact_pulled(
3505        &mut self,
3506        pulled: &crate::runtime::PulledArtifact,
3507    ) -> std::io::Result<()> {
3508        let artifact = &pulled.summary;
3509        let session = match &artifact.origin {
3510            LockOrigin::Runtime { session } => Some(session.clone()),
3511            LockOrigin::Operator => None,
3512        };
3513        let event = ArtifactPulledEvent {
3514            event_type: "artifact_pulled",
3515            event_id: new_event_id(),
3516            parent_id: self.session_parent(),
3517            session_id: self.session_id.clone(),
3518            timestamp: timestamp_ms(),
3519            name: artifact.name.clone(),
3520            version: artifact.version.clone(),
3521            runtime: artifact.runtime.as_str(),
3522            origin: artifact.origin.as_str(),
3523            session,
3524            trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
3525            fetched_from: pulled.fetched_from.clone(),
3526            owner_id: pulled.owner_id,
3527        };
3528        self.write_event(&event).await
3529    }
3530
3531    /// `part_kinds` and `reference_task_ids` describe the A2A message the task arrived as, and are
3532    /// empty for any other task.
3533    #[cfg(test)]
3534    #[allow(clippy::too_many_arguments)]
3535    pub(crate) async fn write_task_start(
3536        &mut self,
3537        task_id: &str,
3538        context_id: &str,
3539        source: &str,
3540        provenance: TaskProvenance,
3541        message_parts_bytes: u64,
3542        part_kinds: &[&'static str],
3543        reference_task_ids: &[String],
3544    ) -> std::io::Result<()> {
3545        self.write_task_start_with_attachments(
3546            task_id,
3547            context_id,
3548            source,
3549            provenance,
3550            message_parts_bytes,
3551            part_kinds,
3552            reference_task_ids,
3553            Vec::new(),
3554        )
3555        .await
3556    }
3557
3558    /// The `task_start` record. `attachments` lists the images the task's message carried, and an
3559    /// empty list omits the field.
3560    #[allow(clippy::too_many_arguments)]
3561    pub(crate) async fn write_task_start_with_attachments(
3562        &mut self,
3563        task_id: &str,
3564        context_id: &str,
3565        source: &str,
3566        provenance: TaskProvenance,
3567        message_parts_bytes: u64,
3568        part_kinds: &[&'static str],
3569        reference_task_ids: &[String],
3570        attachments: Vec<AttachmentRecord>,
3571    ) -> std::io::Result<()> {
3572        // Drained under the ids in force before this task, which the lines below replace.
3573        self.write_call_deadlines().await?;
3574        // Reset per-task counters for this task
3575        self.task_turns = 0;
3576        self.task_input_tokens = 0;
3577        self.task_output_tokens = 0;
3578        self.task_tool_calls = 0;
3579        self.task_shell_calls = 0;
3580        self.task_agent_turns = 0;
3581        self.task_hook_inferences = 0;
3582        self.task_capped_turns = 0;
3583        let started = Instant::now();
3584        self.task_start_instant = Some(started);
3585        self.active_task_id = Some(task_id.to_string());
3586        if let Some(status) = &self.session_status {
3587            status.task_started(task_id, started);
3588        }
3589
3590        let event_id = new_event_id();
3591        self.task_event_id = Some(event_id.clone());
3592        self.turn_event_id = None;
3593
3594        let event = TaskStartEvent {
3595            event_type: "task_start",
3596            event_id: event_id.clone(),
3597            parent_id: self.session_parent(),
3598            session_id: self.session_id.clone(),
3599            timestamp: timestamp_ms(),
3600            task_id: task_id.to_string(),
3601            context_id: context_id.to_string(),
3602            source: source.to_string(),
3603            origin: provenance.origin().as_str().to_string(),
3604            trust: provenance.trust().as_str().to_string(),
3605            // One rule for every call site, so the recorded lane cannot disagree with the lane
3606            // `LaneQueue` filed the task under — both read the same provenance.
3607            lane: TaskLane::for_origin(provenance.origin())
3608                .as_str()
3609                .to_string(),
3610            message_parts_bytes,
3611            part_kinds: part_kinds.to_vec(),
3612            reference_task_ids: reference_task_ids.to_vec(),
3613            attachments,
3614        };
3615        self.write_event(&event).await
3616    }
3617
3618    pub(crate) async fn write_task_end(
3619        &mut self,
3620        task_id: &str,
3621        exit_status: &str,
3622        reopen_count: u32,
3623        running_commands: Vec<String>,
3624        keep_working: Option<KeepWorkingSummary>,
3625        done_check: Option<DoneCheckSummary>,
3626    ) -> std::io::Result<()> {
3627        // Drained inside the task, before the lines below clear its ids.
3628        self.write_call_deadlines().await?;
3629        let duration_ms = self
3630            .task_start_instant
3631            .take()
3632            .map(|t| t.elapsed().as_millis().try_into().unwrap_or(u64::MAX))
3633            .unwrap_or(0);
3634
3635        let event = TaskEndEvent {
3636            event_type: "task_end",
3637            event_id: new_event_id(),
3638            parent_id: self.task_parent(),
3639            session_id: self.session_id.clone(),
3640            timestamp: timestamp_ms(),
3641            task_id: task_id.to_string(),
3642            exit_status: exit_status.to_string(),
3643            duration_ms,
3644            turns: self.task_turns,
3645            input_tokens: self.task_input_tokens,
3646            output_tokens: self.task_output_tokens,
3647            tool_calls: self.task_tool_calls,
3648            shell_calls: self.task_shell_calls,
3649            reopen_count,
3650            capped_turns: self.task_capped_turns,
3651            running_commands,
3652            keep_working,
3653            done_check,
3654        };
3655        self.active_task_id = None;
3656        self.task_event_id = None;
3657        self.turn_event_id = None;
3658        self.task_agent_turns = 0;
3659        if let Some(status) = &self.session_status {
3660            status.task_ended();
3661        }
3662        self.write_event(&event).await
3663    }
3664
3665    /// Record that a person stopped this task, at the point the loop stopped.
3666    ///
3667    /// Leaves `active_task_id` and the task's parent id alone: for a running task the terminal
3668    /// `task_end` still follows and closes the frame. A task cancelled while queued was never
3669    /// started, so this record hangs off the session.
3670    pub(crate) async fn write_task_canceled(
3671        &mut self,
3672        task_id: &str,
3673        turn: Option<u32>,
3674        phase: &str,
3675        detached_work_ids: Vec<String>,
3676        delegation_ids: Vec<String>,
3677    ) -> std::io::Result<()> {
3678        let event = TaskCanceledEvent {
3679            event_type: "task_canceled",
3680            event_id: new_event_id(),
3681            parent_id: self.task_parent(),
3682            session_id: self.session_id.clone(),
3683            timestamp: timestamp_ms(),
3684            task_id: task_id.to_string(),
3685            turn,
3686            phase: phase.to_string(),
3687            detached_work_ids,
3688            delegation_ids,
3689        };
3690        self.write_event(&event).await
3691    }
3692
3693    /// Record that a queued task was refused when the session stopped taking work, with `cause`
3694    /// from the `TASK_REJECTED_*` vocabulary. Written whatever `trace.capture` is.
3695    ///
3696    /// Leaves `active_task_id` alone: the refused task never started, so there is no task frame
3697    /// to open or close, and with no task active the record hangs off the session.
3698    pub(crate) async fn write_task_rejected(
3699        &mut self,
3700        task_id: &str,
3701        context_id: &str,
3702        source: &str,
3703        cause: &str,
3704        reason: &str,
3705    ) -> std::io::Result<()> {
3706        let event = TaskRejectedEvent {
3707            event_type: "task_rejected",
3708            event_id: new_event_id(),
3709            parent_id: self.task_parent(),
3710            session_id: self.session_id.clone(),
3711            timestamp: timestamp_ms(),
3712            task_id: task_id.to_string(),
3713            context_id: context_id.to_string(),
3714            source: source.to_string(),
3715            cause: cause.to_string(),
3716            reason: reason.to_string(),
3717        };
3718        self.write_event(&event).await
3719    }
3720
3721    /// Record that the task attempt in progress failed, with `cause` from the `TASK_FAILED_*`
3722    /// vocabulary. Written whatever `trace.capture` is.
3723    ///
3724    /// Leaves the task frame open, as [`Self::write_task_canceled`] does: the `task_end` that
3725    /// follows closes it. The reason, capped, is kept for [`Self::last_task_failure`].
3726    pub(crate) async fn write_task_failed(
3727        &mut self,
3728        turn: Option<u32>,
3729        cause: &str,
3730        reason: &str,
3731    ) -> std::io::Result<()> {
3732        let reason = crate::agent::truncate_on_char_boundary(reason, MAX_TASK_FAILURE_REASON_BYTES)
3733            .to_string();
3734        let event = TaskFailedEvent {
3735            event_type: "task_failed",
3736            event_id: new_event_id(),
3737            parent_id: self.task_parent(),
3738            session_id: self.session_id.clone(),
3739            timestamp: timestamp_ms(),
3740            task_id: self.active_task_id.clone(),
3741            turn,
3742            cause: cause.to_string(),
3743            reason: reason.clone(),
3744        };
3745        self.task_failures_written += 1;
3746        self.last_task_failure = Some(reason);
3747        self.write_event(&event).await
3748    }
3749
3750    /// Record one refusal the workdir helper latched.
3751    pub(crate) async fn write_workdir_path_refused(
3752        &mut self,
3753        refusal: &crate::workdir_fs::WorkdirRefusal,
3754    ) -> std::io::Result<()> {
3755        let event = WorkdirPathRefusedEvent {
3756            event_type: "workdir_path_refused",
3757            event_id: new_event_id(),
3758            parent_id: self.task_parent(),
3759            session_id: self.session_id.clone(),
3760            timestamp: timestamp_ms(),
3761            task_id: self.active_task_id.clone(),
3762            path: &refusal.path,
3763            operation: refusal.operation,
3764            refused_at: &refusal.refused_at,
3765            reason: refusal.reason,
3766        };
3767        self.write_event(&event).await
3768    }
3769
3770    /// The `reason` of the last `task_failed` line this writer wrote, or `None` before the first.
3771    pub(crate) fn last_task_failure(&self) -> Option<&str> {
3772        self.last_task_failure.as_deref()
3773    }
3774
3775    /// How many `task_failed` lines this writer has written. Compared across one run to tell
3776    /// whether that run wrote its own.
3777    pub(crate) fn task_failures_written(&self) -> u64 {
3778        self.task_failures_written
3779    }
3780
3781    /// Record that an `on-task-end` hook reopened the task. Written by the runtime's
3782    /// per-task reopen loop between two agent-loop attempts, once per reopen, before
3783    /// the terminal `task_end` record. `reopen_number` is 1-based.
3784    pub(crate) async fn write_task_reopened(
3785        &mut self,
3786        task_id: &str,
3787        hook_name: &str,
3788        reason: &str,
3789        reopen_number: u32,
3790        attempt_context: &'static str,
3791        turns_remaining: Option<u32>,
3792    ) -> std::io::Result<()> {
3793        let event = TaskReopenedEvent {
3794            event_type: "task_reopened",
3795            event_id: new_event_id(),
3796            parent_id: self.task_parent(),
3797            session_id: self.session_id.clone(),
3798            timestamp: timestamp_ms(),
3799            task_id: task_id.to_string(),
3800            hook_name: hook_name.to_string(),
3801            reason: reason.to_string(),
3802            reopen_number,
3803            attempt_context,
3804            turns_remaining,
3805        };
3806        self.write_event(&event).await
3807    }
3808
3809    /// Record that the task continues with one round of handed-off work's outcomes. Written by
3810    /// the runtime's per-task loop once per continuation, before the continued attempt's first
3811    /// `inference`. `continuation_number` is 1-based.
3812    pub(crate) async fn write_task_continued(
3813        &mut self,
3814        task_id: &str,
3815        continuation_number: u32,
3816        member_calls: &[String],
3817        delegations: &[String],
3818        waited_ms: u64,
3819        turns_remaining: Option<u32>,
3820    ) -> std::io::Result<()> {
3821        let event = TaskContinuedEvent {
3822            event_type: "task_continued",
3823            event_id: new_event_id(),
3824            parent_id: self.task_parent(),
3825            session_id: self.session_id.clone(),
3826            timestamp: timestamp_ms(),
3827            task_id: task_id.to_string(),
3828            continuation_number,
3829            member_calls,
3830            delegations,
3831            waited_ms,
3832            turns_remaining,
3833        };
3834        self.write_event(&event).await
3835    }
3836
3837    /// Record that `lifecycle.keep_working` sent its message as the task's `number`th continuation
3838    /// (1-based), before the continued attempt's first `inference`.
3839    pub(crate) async fn write_keep_working(
3840        &mut self,
3841        task_id: &str,
3842        number: u32,
3843        limit: u32,
3844        turns_remaining: Option<u32>,
3845        message_bytes: u64,
3846    ) -> std::io::Result<()> {
3847        let event = KeepWorkingEvent {
3848            event_type: "keep_working",
3849            event_id: new_event_id(),
3850            parent_id: self.task_parent(),
3851            session_id: self.session_id.clone(),
3852            timestamp: timestamp_ms(),
3853            task_id: task_id.to_string(),
3854            number,
3855            limit,
3856            turns_remaining,
3857            message_bytes,
3858        };
3859        if let Some(status) = &self.session_status {
3860            status.keep_working_sent(number);
3861        }
3862        self.write_event(&event).await
3863    }
3864
3865    /// Record one run of `lifecycle.done_check`'s command. A run that reopened the task feeds the
3866    /// session status the task's reopens so far, `reopens`.
3867    pub(crate) async fn write_done_check(
3868        &mut self,
3869        run: &DoneCheckRun<'_>,
3870        reopens: u32,
3871    ) -> std::io::Result<()> {
3872        let event = DoneCheckEvent {
3873            event_type: "done_check",
3874            event_id: new_event_id(),
3875            parent_id: self.task_parent(),
3876            session_id: self.session_id.clone(),
3877            timestamp: timestamp_ms(),
3878            run,
3879        };
3880        if run.reopened {
3881            if let Some(status) = &self.session_status {
3882                status.done_check_reopened(reopens);
3883            }
3884        }
3885        self.write_event(&event).await
3886    }
3887
3888    /// Cumulative turns consumed by the active task so far, across however many
3889    /// agent-loop attempts have run since the last [`Self::write_task_start`]. Read by
3890    /// the reopen loop to compute the remaining `max_turns` budget for the next attempt
3891    /// so reopening never grants turns past the capsule's ceiling.
3892    ///
3893    /// A hook's counted `run-inference` call is one of them. `inference.max_turns` is the cap on
3894    /// inference calls per task, and `task_end.turns` and `session_end.total_turns` count the
3895    /// same calls; a hook that calls `run-inference` every turn is per-task spend that the cap
3896    /// exists to bound, and the spend ceiling is per capsule, not per task.
3897    pub(crate) fn task_turns(&self) -> u32 {
3898        self.task_turns
3899    }
3900
3901    /// How many of [`Self::task_turns`] were hook `run-inference` calls.
3902    pub(crate) fn task_hook_inferences(&self) -> u32 {
3903        self.task_hook_inferences
3904    }
3905
3906    /// The number the active task's next agent-loop turn takes: how many agent-loop `inference`
3907    /// lines it has written since [`Self::write_task_start`], across every attempt. 0 outside a
3908    /// task frame. Not the `max_turns` budget, which is [`Self::task_turns`].
3909    pub(crate) fn task_agent_turns(&self) -> u32 {
3910        self.task_agent_turns
3911    }
3912
3913    pub(crate) async fn write_compaction(
3914        &mut self,
3915        turn: u32,
3916        tokens_before: u64,
3917        tokens_before_source: OccupancyBasis,
3918        tokens_after: u64,
3919    ) -> std::io::Result<()> {
3920        let event = CompactionEvent {
3921            event_type: "compaction",
3922            event_id: new_event_id(),
3923            parent_id: self.turn_parent(),
3924            session_id: self.session_id.clone(),
3925            timestamp: timestamp_ms(),
3926            turn,
3927            task_id: self.active_task_id.clone(),
3928            tokens_before,
3929            tokens_before_source,
3930            tokens_after,
3931        };
3932        self.write_event(&event).await
3933    }
3934
3935    /// Record that compaction was attempted and declined, leaving the context over budget.
3936    /// `tokens` is the occupancy at the moment of the decline and `tokens_source` the kind of
3937    /// number it is; `reason` is
3938    /// [`COMPACTION_DECLINED_NO_HOOK_REPLACEMENT`] or
3939    /// [`COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL`].
3940    pub(crate) async fn write_compaction_declined(
3941        &mut self,
3942        turn: u32,
3943        tokens: u64,
3944        tokens_source: OccupancyBasis,
3945        reason: &str,
3946    ) -> std::io::Result<()> {
3947        let event = CompactionDeclinedEvent {
3948            event_type: "compaction_declined",
3949            event_id: new_event_id(),
3950            parent_id: self.turn_parent(),
3951            session_id: self.session_id.clone(),
3952            timestamp: timestamp_ms(),
3953            turn,
3954            task_id: self.active_task_id.clone(),
3955            tokens,
3956            tokens_source,
3957            reason: reason.to_string(),
3958        };
3959        self.write_event(&event).await
3960    }
3961
3962    /// Record what the runtime did with an `on-task-start` hook's `seed-context`.
3963    ///
3964    /// Written for every outcome, committed or not: `tokens` is what reached the context
3965    /// (`0` on a rejection), `proposed_tokens` is what the hook returned, and
3966    /// `budget_tokens` is the ceiling it was measured against. `reason` is `Some` only for
3967    /// [`SEED_OUTCOME_REJECTED`]. Task-scoped, like `task_start`/`task_end`.
3968    #[allow(clippy::too_many_arguments)]
3969    pub(crate) async fn write_context_seed(
3970        &mut self,
3971        hook_name: &str,
3972        tokens: u64,
3973        proposed_tokens: u64,
3974        budget_tokens: u64,
3975        outcome: &str,
3976        reason: Option<&str>,
3977        message_ids: Vec<String>,
3978    ) -> std::io::Result<()> {
3979        let event = ContextSeedEvent {
3980            event_type: "context_seed",
3981            event_id: new_event_id(),
3982            parent_id: self.task_parent(),
3983            session_id: self.session_id.clone(),
3984            timestamp: timestamp_ms(),
3985            task_id: self.active_task_id.clone(),
3986            hook_name: hook_name.to_string(),
3987            tokens,
3988            proposed_tokens,
3989            budget_tokens,
3990            outcome: outcome.to_string(),
3991            reason: reason.map(str::to_string),
3992            message_ids,
3993        };
3994        self.write_event(&event).await
3995    }
3996
3997    /// Record one store's deletion, naming the reason and everything that went.
3998    ///
3999    /// Session-scoped: `parent_id` is the session node, so the deletion hangs off the launch that
4000    /// performed it rather than off whatever task happened to be open. `messages_dropped` is
4001    /// `Some` only for [`RETENTION_REASON_MAX_MESSAGES`].
4002    pub(crate) async fn write_retention(
4003        &mut self,
4004        store: &str,
4005        reason: &str,
4006        targets: Vec<String>,
4007        messages_dropped: Option<u64>,
4008    ) -> std::io::Result<()> {
4009        let event = RetentionEvent {
4010            event_type: "retention",
4011            event_id: new_event_id(),
4012            parent_id: self.session_parent(),
4013            session_id: self.session_id.clone(),
4014            timestamp: timestamp_ms(),
4015            store: store.to_string(),
4016            reason: reason.to_string(),
4017            removed: u32::try_from(targets.len()).unwrap_or(u32::MAX),
4018            targets,
4019            messages_dropped,
4020        };
4021        self.write_event(&event).await
4022    }
4023
4024    /// Record that a policy hook refused a call before it ran. The only account of a call the
4025    /// model asked for and never got: nothing ran, so no `tool_call` or `shell` record
4026    /// accompanies it.
4027    pub(crate) async fn write_call_denied(
4028        &mut self,
4029        turn: u32,
4030        event: &str,
4031        hook_name: &str,
4032        target: &str,
4033        reason: &str,
4034    ) -> std::io::Result<()> {
4035        let event = CallDeniedEvent {
4036            event_type: "call_denied",
4037            event_id: new_event_id(),
4038            parent_id: self.turn_parent(),
4039            session_id: self.session_id.clone(),
4040            timestamp: timestamp_ms(),
4041            turn,
4042            event: event.to_string(),
4043            hook_name: hook_name.to_string(),
4044            target: target.to_string(),
4045            reason: reason.to_string(),
4046        };
4047        self.write_event(&event).await
4048    }
4049
4050    /// Record that a call was refused because its input lacked a field its tool's `input_schema`
4051    /// requires. A `tool_call` record with `status: "error"` follows it on both transports, so
4052    /// unlike [`Self::write_call_denied`] this is not the only line the call leaves. An empty
4053    /// `tool_call_id` records as `null`, as in [`Self::write_tool_call`].
4054    pub(crate) async fn write_tool_input_refused(
4055        &mut self,
4056        turn: u32,
4057        tool_name: &str,
4058        tool_call_id: Option<&str>,
4059        missing: &[String],
4060        reason: &str,
4061    ) -> std::io::Result<()> {
4062        let event = ToolInputRefusedEvent {
4063            event_type: "tool_input_refused",
4064            event_id: new_event_id(),
4065            parent_id: self.turn_parent(),
4066            session_id: self.session_id.clone(),
4067            timestamp: timestamp_ms(),
4068            turn,
4069            tool_name: tool_name.to_string(),
4070            tool_call_id: tool_call_id.filter(|id| !id.is_empty()).map(str::to_string),
4071            missing: missing.to_vec(),
4072            reason: reason.to_string(),
4073        };
4074        self.write_event(&event).await
4075    }
4076
4077    /// Record that a driver call failed transiently and is about to be made again after
4078    /// `retry.delay_ms`. `retry.origin` is the `hook:<name>` of a hook's `run-inference` call,
4079    /// `None` for an agent-loop turn. The error text is capped at
4080    /// [`MAX_TASK_FAILURE_REASON_BYTES`].
4081    pub(crate) async fn write_inference_retry(
4082        &mut self,
4083        turn: u32,
4084        retry: &InferenceRetryRecord,
4085    ) -> std::io::Result<()> {
4086        let event = InferenceRetryEvent {
4087            event_type: "inference_retry",
4088            event_id: new_event_id(),
4089            parent_id: self.turn_parent(),
4090            session_id: self.session_id.clone(),
4091            timestamp: timestamp_ms(),
4092            turn,
4093            task_id: self.active_task_id.clone(),
4094            attempt: retry.attempt,
4095            max_retries: retry.max_retries,
4096            reason: retry.reason.wire_name(),
4097            provider_status: retry.provider_status,
4098            error: crate::agent::truncate_on_char_boundary(
4099                &retry.error,
4100                MAX_TASK_FAILURE_REASON_BYTES,
4101            ),
4102            delay_ms: retry.delay_ms,
4103            retry_after_ms: retry.retry_after_ms,
4104            charged_tokens: retry.charged_tokens,
4105            origin: retry.origin.as_deref(),
4106        };
4107        self.write_event(&event).await
4108    }
4109
4110    /// Record that a spend ceiling refused a driver call before it was sent. `origin` is the
4111    /// `hook:<name>` of a hook's `run-inference` call, `None` for an agent-loop turn.
4112    pub(crate) async fn write_spend_ceiling_reached(
4113        &mut self,
4114        turn: u32,
4115        refusal: &crate::spend::SpendRefusal,
4116        origin: Option<&str>,
4117    ) -> std::io::Result<()> {
4118        let event = SpendCeilingReachedEvent {
4119            event_type: "spend_ceiling_reached",
4120            event_id: new_event_id(),
4121            parent_id: self.turn_parent(),
4122            session_id: self.session_id.clone(),
4123            timestamp: timestamp_ms(),
4124            turn,
4125            task_id: self.active_task_id.clone(),
4126            refusal,
4127            origin,
4128        };
4129        self.write_event(&event).await
4130    }
4131
4132    /// Record that the manifest's `capabilities.filesystem.read_only` refused a call before it
4133    /// ran. Like [`Self::write_call_denied`] it is the only account of a call the model asked for
4134    /// and never got — nothing ran, so no `tool_call` or `shell` record accompanies it.
4135    #[allow(clippy::too_many_arguments)]
4136    pub(crate) async fn write_protected_path_denied(
4137        &mut self,
4138        turn: u32,
4139        call: &str,
4140        target: &str,
4141        path: &str,
4142        rule: &str,
4143        signal: &str,
4144        reason: &str,
4145    ) -> std::io::Result<()> {
4146        let event = ProtectedPathDeniedEvent {
4147            event_type: "protected_path_denied",
4148            event_id: new_event_id(),
4149            parent_id: self.turn_parent(),
4150            session_id: self.session_id.clone(),
4151            timestamp: timestamp_ms(),
4152            turn,
4153            call: call.to_string(),
4154            target: target.to_string(),
4155            path: path.to_string(),
4156            rule: rule.to_string(),
4157            signal: signal.to_string(),
4158            reason: reason.to_string(),
4159        };
4160        self.write_event(&event).await
4161    }
4162
4163    /// Record that a hook returned a `hook-output` arm the lifecycle event does not
4164    /// honor. Written by the agent loop from a buffered [`crate::hooks::DispatchFault`]
4165    /// drained just before `session_end`. Non-fatal: the session already continued as
4166    /// if the hook had returned `none`; this only makes the discard visible to
4167    /// `mur trace show` and anything reading the session trace.
4168    pub(crate) async fn write_hook_dispatch_error(
4169        &mut self,
4170        hook_name: &str,
4171        event: &str,
4172        arm: &str,
4173    ) -> std::io::Result<()> {
4174        let event = HookDispatchErrorEvent {
4175            event_type: "hook_dispatch_error",
4176            event_id: new_event_id(),
4177            parent_id: self.session_parent(),
4178            session_id: self.session_id.clone(),
4179            timestamp: timestamp_ms(),
4180            hook_name: hook_name.to_string(),
4181            event: event.to_string(),
4182            arm: arm.to_string(),
4183        };
4184        self.write_event(&event).await
4185    }
4186
4187    // ── The harness a `transport: process` session drives ────────────────────────────────
4188    //
4189    // Eight events account for one harness run: what was spawned, what it said about itself,
4190    // what it warned or noted, how it failed, how it was stopped, and how it ended. None of them
4191    // ever carries an argument value, an environment value or a file's contents — the bridge's
4192    // bearer token reaches the harness through exactly those three, and `trace.jsonl` is durable.
4193
4194    /// Record the harness about to be spawned: written after the driver planned the run and
4195    /// before the process exists, so a spawn that goes wrong still has its plan on record.
4196    pub(crate) async fn write_harness_start(&mut self, start: HarnessStart) -> std::io::Result<()> {
4197        if self.capture.captures_content() {
4198            self.blobs
4199                .put(&start.system_prompt_sha256, start.system_prompt.as_bytes())
4200                .await?;
4201        }
4202        let event = HarnessStartEvent {
4203            event_type: "harness_start",
4204            event_id: new_event_id(),
4205            parent_id: self.task_parent(),
4206            session_id: self.session_id.clone(),
4207            timestamp: timestamp_ms(),
4208            task_id: self.active_task_id.clone(),
4209            start,
4210        };
4211        self.write_event(&event).await
4212    }
4213
4214    /// Record a warning the run raised, beside the line it printed to stderr.
4215    pub(crate) async fn write_harness_warning(
4216        &mut self,
4217        code: &str,
4218        message: &str,
4219    ) -> std::io::Result<()> {
4220        let event = HarnessWarningEvent {
4221            event_type: "harness_warning",
4222            event_id: new_event_id(),
4223            parent_id: self.task_parent(),
4224            session_id: self.session_id.clone(),
4225            timestamp: timestamp_ms(),
4226            task_id: self.active_task_id.clone(),
4227            code: code.to_string(),
4228            message: message.to_string(),
4229        };
4230        self.write_event(&event).await
4231    }
4232
4233    /// Record the session the harness reports it opened, and how it is billing for it.
4234    pub(crate) async fn write_harness_session(
4235        &mut self,
4236        harness_session_id: &str,
4237        auth: &str,
4238        model: Option<&str>,
4239    ) -> std::io::Result<()> {
4240        let event = HarnessSessionEvent {
4241            event_type: "harness_session",
4242            event_id: new_event_id(),
4243            parent_id: self.task_parent(),
4244            session_id: self.session_id.clone(),
4245            timestamp: timestamp_ms(),
4246            task_id: self.active_task_id.clone(),
4247            harness_session_id: harness_session_id.to_string(),
4248            auth: auth.to_string(),
4249            model: model.map(str::to_string),
4250        };
4251        self.write_event(&event).await
4252    }
4253
4254    /// Record the harness session a person asked this capsule to forget, and who asked.
4255    pub(crate) async fn write_harness_session_forgotten(
4256        &mut self,
4257        context_id: &str,
4258        harness_session_id: &str,
4259        requested_by: &str,
4260    ) -> std::io::Result<()> {
4261        let event = HarnessSessionForgottenEvent {
4262            event_type: "harness_session_forgotten",
4263            event_id: new_event_id(),
4264            parent_id: self.task_parent(),
4265            session_id: self.session_id.clone(),
4266            timestamp: timestamp_ms(),
4267            task_id: self.active_task_id.clone(),
4268            context_id: context_id.to_string(),
4269            harness_session_id: harness_session_id.to_string(),
4270            requested_by: requested_by.to_string(),
4271        };
4272        self.write_event(&event).await
4273    }
4274
4275    /// Record that the harness is retrying a request to its own provider.
4276    pub(crate) async fn write_harness_retry(
4277        &mut self,
4278        attempt: u32,
4279        reason: &str,
4280    ) -> std::io::Result<()> {
4281        let event = HarnessRetryEvent {
4282            event_type: "harness_retry",
4283            event_id: new_event_id(),
4284            parent_id: self.task_parent(),
4285            session_id: self.session_id.clone(),
4286            timestamp: timestamp_ms(),
4287            task_id: self.active_task_id.clone(),
4288            attempt,
4289            reason: reason.to_string(),
4290        };
4291        self.write_event(&event).await
4292    }
4293
4294    /// Record a diagnostic the driver produced for the trace alone — a line it could not read,
4295    /// or a result the runtime could not pair with a call.
4296    pub(crate) async fn write_harness_note(&mut self, text: &str) -> std::io::Result<()> {
4297        let event = HarnessTextEvent {
4298            event_type: "harness_note",
4299            event_id: new_event_id(),
4300            parent_id: self.task_parent(),
4301            session_id: self.session_id.clone(),
4302            timestamp: timestamp_ms(),
4303            task_id: self.active_task_id.clone(),
4304            text: text.to_string(),
4305        };
4306        self.write_event(&event).await
4307    }
4308
4309    /// Record one report of the subscription windows the harness says are used, already
4310    /// validated, and hand it to the session status, which keeps the latest window per name.
4311    pub(crate) async fn write_harness_quota(
4312        &mut self,
4313        windows: &[crate::process_driver::QuotaWindow],
4314    ) -> std::io::Result<()> {
4315        if let Some(status) = &self.session_status {
4316            status.record_quota(windows);
4317        }
4318        let event = HarnessQuotaEvent {
4319            event_type: "harness_quota",
4320            event_id: new_event_id(),
4321            parent_id: self.task_parent(),
4322            session_id: self.session_id.clone(),
4323            timestamp: timestamp_ms(),
4324            task_id: self.active_task_id.clone(),
4325            windows: windows
4326                .iter()
4327                .map(|window| HarnessQuotaWindow {
4328                    name: &window.name,
4329                    used: window.used,
4330                    resets_at_ms: window.resets_at,
4331                    window_minutes: window.window_minutes,
4332                    state: window
4333                        .state
4334                        .map(crate::process_driver::QuotaState::wire_name),
4335                })
4336                .collect(),
4337        };
4338        self.write_event(&event).await
4339    }
4340
4341    /// Record the harness's prediction of what the person will ask next.
4342    pub(crate) async fn write_harness_suggestion(&mut self, text: &str) -> std::io::Result<()> {
4343        let event = HarnessTextEvent {
4344            event_type: "harness_suggestion",
4345            event_id: new_event_id(),
4346            parent_id: self.task_parent(),
4347            session_id: self.session_id.clone(),
4348            timestamp: timestamp_ms(),
4349            task_id: self.active_task_id.clone(),
4350            text: text.to_string(),
4351        };
4352        self.write_event(&event).await
4353    }
4354
4355    /// Record a failed turn. `source` is `"harness"` when the harness reported the failure and
4356    /// `"runtime"` when this runtime ended the turn itself.
4357    pub(crate) async fn write_harness_failed(
4358        &mut self,
4359        kind: &str,
4360        message: &str,
4361        source: &str,
4362    ) -> std::io::Result<()> {
4363        let event = HarnessFailedEvent {
4364            event_type: "harness_failed",
4365            event_id: new_event_id(),
4366            parent_id: self.task_parent(),
4367            session_id: self.session_id.clone(),
4368            timestamp: timestamp_ms(),
4369            task_id: self.active_task_id.clone(),
4370            kind: kind.to_string(),
4371            message: message.to_string(),
4372            source: source.to_string(),
4373        };
4374        self.write_event(&event).await
4375    }
4376
4377    /// Record the interrupt a cancelled task sent its harness: the method the driver declared,
4378    /// whether it went out, and how long the harness has before it is killed.
4379    pub(crate) async fn write_harness_interrupt(
4380        &mut self,
4381        method: &str,
4382        delivered: bool,
4383        grace_ms: u64,
4384    ) -> std::io::Result<()> {
4385        let event = HarnessInterruptEvent {
4386            event_type: "harness_interrupt",
4387            event_id: new_event_id(),
4388            parent_id: self.task_parent(),
4389            session_id: self.session_id.clone(),
4390            timestamp: timestamp_ms(),
4391            task_id: self.active_task_id.clone(),
4392            method: method.to_string(),
4393            delivered,
4394            grace_ms,
4395        };
4396        self.write_event(&event).await
4397    }
4398
4399    /// Record how the harness ended: once per spawned process, whatever ended it.
4400    pub(crate) async fn write_harness_exit(&mut self, exit: HarnessExit) -> std::io::Result<()> {
4401        let event = HarnessExitEvent {
4402            event_type: "harness_exit",
4403            event_id: new_event_id(),
4404            parent_id: self.task_parent(),
4405            session_id: self.session_id.clone(),
4406            timestamp: timestamp_ms(),
4407            task_id: self.active_task_id.clone(),
4408            exit,
4409        };
4410        self.write_event(&event).await
4411    }
4412
4413    pub(crate) async fn write_session_end(&mut self, exit_status: &str) -> std::io::Result<()> {
4414        let duration_ms = self
4415            .session_start_time
4416            .elapsed()
4417            .as_millis()
4418            .try_into()
4419            .unwrap_or(u64::MAX);
4420        let event = SessionEndEvent {
4421            event_type: "session_end",
4422            event_id: new_event_id(),
4423            parent_id: self.session_parent(),
4424            session_id: self.session_id.clone(),
4425            timestamp: timestamp_ms(),
4426            total_turns: self.total_turns,
4427            total_input_tokens: self.total_input_tokens,
4428            total_output_tokens: self.total_output_tokens,
4429            total_tool_calls: self.total_tool_calls,
4430            total_shell_calls: self.total_shell_calls,
4431            duration_ms,
4432            exit_status: exit_status.to_string(),
4433        };
4434        self.write_event(&event).await?;
4435        self.session_ended = true;
4436        Ok(())
4437    }
4438
4439    /// Writes session_end with the accumulated totals if session_start was written but
4440    /// session_end was not (error exit paths that bypass the normal Ok() return sites).
4441    pub(crate) async fn write_session_end_if_not_ended(
4442        &mut self,
4443        exit_status: &str,
4444    ) -> std::io::Result<()> {
4445        if self.session_started && !self.session_ended {
4446            self.write_session_end(exit_status).await?;
4447        }
4448        Ok(())
4449    }
4450
4451    pub(crate) async fn flush(&mut self) -> std::io::Result<()> {
4452        self.writer.flush().await
4453    }
4454
4455    /// Write `event` as one line, after a `call_deadline` line for each firing the session's
4456    /// latch holds. Draining here, ahead of every line, is what puts a cut call's
4457    /// `call_deadline` before the record of that call and every one before `session_end`.
4458    async fn write_event(&mut self, event: &impl Serialize) -> std::io::Result<()> {
4459        self.write_call_deadlines().await?;
4460        self.write_line(event).await
4461    }
4462
4463    /// One `call_deadline` line per firing [`Self::call_deadlines`] holds, parented to the turn
4464    /// as `inference_retry` is. Nothing before `session_start`, which every line hangs off.
4465    async fn write_call_deadlines(&mut self) -> std::io::Result<()> {
4466        let Some(deadlines) = self
4467            .call_deadlines
4468            .as_ref()
4469            .filter(|_| self.session_started)
4470        else {
4471            return Ok(());
4472        };
4473        for cut in deadlines.take() {
4474            let event = CallDeadlineEvent {
4475                event_type: "call_deadline",
4476                event_id: new_event_id(),
4477                parent_id: self.turn_parent(),
4478                session_id: self.session_id.clone(),
4479                timestamp: timestamp_ms(),
4480                task_id: self.active_task_id.clone(),
4481                component: cut.component,
4482                role: cut.role.wire_name(),
4483                call: cut.call,
4484                deadline_seconds: cut.deadline_seconds,
4485                elapsed_ms: cut.elapsed_ms,
4486            };
4487            self.write_line(&event).await?;
4488        }
4489        Ok(())
4490    }
4491
4492    /// Writes one event as one line, and flushes.
4493    ///
4494    /// The flush is not an optimisation choice: [`ResourceTraceAppender`] appends to this same
4495    /// `trace.jsonl` while a task runs, so a line left half-buffered here would be split around
4496    /// whatever the appender wrote in between and the file would stop parsing as JSONL. The
4497    /// two writers agree on one rule — a complete line per write, then flush — and that rule is
4498    /// what makes concurrent reads during a running task safe to record.
4499    async fn write_line(&mut self, event: &impl Serialize) -> std::io::Result<()> {
4500        let mut line = serde_json::to_string(event).map_err(std::io::Error::other)?;
4501        line.push('\n');
4502        self.writer.write_all(line.as_bytes()).await?;
4503        self.writer.flush().await
4504    }
4505}
4506
4507/// One element of `session_start.gateways`: an artifact's credential gateway. Never the key.
4508#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4509pub(crate) struct SessionGateway {
4510    /// The artifact whose entry declares the gateway.
4511    pub(crate) artifact: String,
4512    /// The upstream's host, with its port when `gateway.endpoint` wrote one.
4513    pub(crate) host: String,
4514    /// `"config"`, `"environment"`, `"manifest"`, or `"none"` when the entry names no key.
4515    pub(crate) credential_source: &'static str,
4516    /// Whether the gateway is admitted against the spend meter: true only for the configured
4517    /// `transport: http` driver's.
4518    pub(crate) metered: bool,
4519}
4520
4521/// One element of `session_start.runtime_artifacts`: a staged artifact whose `murmur.lock` pin a
4522/// running capsule fetched.
4523#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4524pub(crate) struct RuntimeArtifact {
4525    name: String,
4526    version: String,
4527    /// Always `"runtime"`: only runtime-origin entries are listed.
4528    origin: &'static str,
4529    /// The session the lock entry names as the puller.
4530    session: String,
4531    /// Always `"untrusted"`, derived from `origin`.
4532    trust: &'static str,
4533}
4534
4535/// One element of `session_start.host_facts`.
4536#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4537pub(crate) struct HostFactsRecord {
4538    /// The binary, canonical.
4539    binary: String,
4540    /// `"kept"` or `"derived"`.
4541    source: &'static str,
4542    /// Why the facts were derived, as [`crate::host_facts::HostFactsReason::wire_name`] spells
4543    /// it. Omitted for `"kept"`.
4544    #[serde(skip_serializing_if = "Option::is_none")]
4545    reason: Option<&'static str>,
4546    /// The first path whose stamp differed. Written only for the reason `"changed"`.
4547    #[serde(skip_serializing_if = "Option::is_none")]
4548    changed_path: Option<String>,
4549}
4550
4551impl HostFactsRecord {
4552    fn of(used: &crate::host_facts::HostFactsUse) -> Self {
4553        use crate::host_facts::HostFactsSource;
4554
4555        let binary = used.binary.display().to_string();
4556        match &used.source {
4557            HostFactsSource::Kept => Self {
4558                binary,
4559                source: "kept",
4560                reason: None,
4561                changed_path: None,
4562            },
4563            HostFactsSource::Derived {
4564                reason,
4565                changed_path,
4566            } => Self {
4567                binary,
4568                source: "derived",
4569                reason: Some(reason.wire_name()),
4570                changed_path: changed_path.as_ref().map(|path| path.display().to_string()),
4571            },
4572        }
4573    }
4574}
4575
4576/// `session_start.control`: the names a capsule's `control:` block declares, never a value.
4577#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4578pub(crate) struct SessionControl {
4579    /// Controllable setting names, as `control.settings` lists them.
4580    pub(crate) settings: Vec<&'static str>,
4581    /// Controller-supplied secret names, as `control.secrets` lists them.
4582    pub(crate) secrets: Vec<String>,
4583    /// Setting names the agent may change, as `control.agent_settings` lists them. Omitted when
4584    /// the block grants the agent nothing.
4585    #[serde(skip_serializing_if = "Vec::is_empty")]
4586    pub(crate) agent_settings: Vec<&'static str>,
4587}
4588
4589/// One element of `session_start.inference_choices`: a driver choice and whether its credential
4590/// resolved at launch. Never a key.
4591#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4592pub(crate) struct SessionInferenceChoice {
4593    pub(crate) name: String,
4594    pub(crate) driver: String,
4595    pub(crate) model: String,
4596    /// Where the driver's gateway credential came from, as `session_start.gateways` names it, or
4597    /// `"none"` for one found nowhere.
4598    pub(crate) credential_source: &'static str,
4599    /// Whether the credential resolved at launch. An injected credential is `true` here and is
4600    /// checked again when a switch asks for it.
4601    pub(crate) available: bool,
4602    /// The choice's own output cap and context window in force, and where each came from.
4603    pub(crate) model_limits: Option<crate::model_limits::ResolvedModelLimits>,
4604}
4605
4606/// One accepted setting or secret change, as [`ResourceTraceAppender::write_control_change`]
4607/// records it. `event_id` is minted by the caller, because the setting it changed carries it
4608/// forward to the `control_applied` that names it.
4609pub(crate) struct ControlChange<'a> {
4610    pub(crate) event_id: String,
4611    /// Who made the change.
4612    pub(crate) principal: crate::control_plane::Principal,
4613    /// The control token's id for a controller's change; `None` for the agent's, which presents
4614    /// no token.
4615    pub(crate) token_id: Option<&'a str>,
4616    /// `"setting"` or `"secret"`.
4617    pub(crate) kind: &'static str,
4618    pub(crate) name: &'a str,
4619    /// `"set"` or `"forget"`.
4620    pub(crate) action: &'static str,
4621    /// A setting's value before and after. Always `None` for a secret.
4622    pub(crate) previous: Option<Value>,
4623    pub(crate) value: Option<Value>,
4624    /// When a setting change takes effect. `None` for a secret.
4625    pub(crate) applies_from: Option<&'static str>,
4626    /// For a secret `set`: whether it replaced a held value.
4627    pub(crate) replaced: Option<bool>,
4628}
4629
4630/// `control_change`: a controller changed a setting or a secret. A secret's line names it and
4631/// carries nothing of its value.
4632#[derive(Serialize)]
4633struct ControlChangeEvent<'a> {
4634    event_type: &'static str,
4635    event_id: String,
4636    parent_id: Option<String>,
4637    session_id: String,
4638    timestamp: u64,
4639    principal: &'static str,
4640    #[serde(skip_serializing_if = "Option::is_none")]
4641    token_id: Option<&'a str>,
4642    kind: &'static str,
4643    name: &'a str,
4644    action: &'static str,
4645    #[serde(skip_serializing_if = "Option::is_none")]
4646    previous: Option<Value>,
4647    #[serde(skip_serializing_if = "Option::is_none")]
4648    value: Option<Value>,
4649    #[serde(skip_serializing_if = "Option::is_none")]
4650    applies_from: Option<&'static str>,
4651    #[serde(skip_serializing_if = "Option::is_none")]
4652    replaced: Option<bool>,
4653}
4654
4655/// `control_refused`: the control plane refused a request, or the runtime refused the agent's
4656/// change. `kind`, `name` and `token_id` are written only once a controller's request
4657/// authenticated, so a `401` names nothing; the agent's refusal names its setting and no token.
4658#[derive(Serialize)]
4659struct ControlRefusedEvent<'a> {
4660    event_type: &'static str,
4661    event_id: String,
4662    parent_id: Option<String>,
4663    session_id: String,
4664    timestamp: u64,
4665    principal: &'static str,
4666    status: u16,
4667    reason: &'static str,
4668    #[serde(skip_serializing_if = "Option::is_none")]
4669    kind: Option<&'static str>,
4670    #[serde(skip_serializing_if = "Option::is_none")]
4671    name: Option<&'a str>,
4672    #[serde(skip_serializing_if = "Option::is_none")]
4673    token_id: Option<&'a str>,
4674}
4675
4676/// `control_applied`: the first inference call to use a setting value a controller set.
4677#[derive(Serialize)]
4678struct ControlAppliedEvent<'a> {
4679    event_type: &'static str,
4680    event_id: String,
4681    parent_id: Option<String>,
4682    session_id: String,
4683    timestamp: u64,
4684    turn: u32,
4685    task_id: Option<String>,
4686    name: &'a str,
4687    value: Value,
4688    change_id: &'a str,
4689}
4690
4691/// `tools_refreshed`: the first inference call to send a tool array rebuilt after an install.
4692#[derive(Serialize)]
4693struct ToolsRefreshedEvent<'a> {
4694    event_type: &'static str,
4695    event_id: String,
4696    parent_id: Option<String>,
4697    session_id: String,
4698    timestamp: u64,
4699    turn: u32,
4700    task_id: Option<String>,
4701    trigger: &'a str,
4702    added: &'a [String],
4703    removed: &'a [String],
4704    tools: &'a [String],
4705}
4706
4707/// `gateway_credential`: the `inference_credential` event for the credential of any artifact other
4708/// than the configured inference driver, with the artifact's name.
4709#[derive(Serialize)]
4710struct GatewayCredentialEvent<'a> {
4711    event_type: &'static str,
4712    event_id: String,
4713    parent_id: Option<String>,
4714    session_id: String,
4715    timestamp: u64,
4716    artifact: &'a str,
4717    source: &'static str,
4718    #[serde(skip_serializing_if = "Option::is_none")]
4719    credential: Option<&'a str>,
4720    change: &'static str,
4721    #[serde(skip_serializing_if = "Option::is_none")]
4722    trigger: Option<&'static str>,
4723    #[serde(skip_serializing_if = "Option::is_none")]
4724    status: Option<u16>,
4725    #[serde(skip_serializing_if = "Option::is_none")]
4726    retried: Option<bool>,
4727    #[serde(skip_serializing_if = "Option::is_none")]
4728    reason: Option<&'static str>,
4729}
4730
4731/// `inference_credential`: a rotation, rejection or unreadable source for the session's inference
4732/// credential. `trigger` is written only with `change: "rotated"`, `status` and `retried` only with
4733/// `"rejected"`, `reason` only with `"unreadable"`, and `credential` whenever the manifest named one.
4734#[derive(Serialize)]
4735struct InferenceCredentialEvent<'a> {
4736    event_type: &'static str,
4737    event_id: String,
4738    parent_id: Option<String>,
4739    session_id: String,
4740    timestamp: u64,
4741    source: &'static str,
4742    #[serde(skip_serializing_if = "Option::is_none")]
4743    credential: Option<&'a str>,
4744    change: &'static str,
4745    #[serde(skip_serializing_if = "Option::is_none")]
4746    trigger: Option<&'static str>,
4747    #[serde(skip_serializing_if = "Option::is_none")]
4748    status: Option<u16>,
4749    #[serde(skip_serializing_if = "Option::is_none")]
4750    retried: Option<bool>,
4751    #[serde(skip_serializing_if = "Option::is_none")]
4752    reason: Option<&'static str>,
4753}
4754
4755/// A credential change as the `change`, `trigger`, `status`, `retried` and `reason` fields of an
4756/// `inference_credential` or `gateway_credential` event.
4757#[allow(clippy::type_complexity)]
4758fn credential_change_fields(
4759    change: CredentialChange,
4760) -> (
4761    &'static str,
4762    Option<&'static str>,
4763    Option<u16>,
4764    Option<bool>,
4765    Option<&'static str>,
4766) {
4767    match change {
4768        CredentialChange::Rotated { trigger } => ("rotated", Some(trigger), None, None, None),
4769        CredentialChange::Rejected { status, retried } => {
4770            ("rejected", None, Some(status), Some(retried), None)
4771        }
4772        CredentialChange::Unreadable { reason } => ("unreadable", None, None, None, Some(reason)),
4773    }
4774}
4775
4776// ── Resource-plane appender ───────────────────────────────────────────────────
4777
4778/// A second, independent `O_APPEND` handle to the session's `trace.jsonl`, used by the resource
4779/// plane, by the peer plane's listener, and by the peer-handoff tools in the agent loop.
4780///
4781/// [`TraceWriter`] is `&mut`-owned by the agent loop and cannot be shared, and the motivating
4782/// case for a resource-plane event — a gateway reading a finished-but-alive capsule — happens
4783/// after `session_end` has already been written. So the plane gets its own handle rather than a
4784/// borrow of the loop's, and every event is written at the moment of the request rather than
4785/// deferred to a task boundary: a denied read that only surfaced at the next task end would be a
4786/// record of the wrong thing at the wrong time.
4787///
4788/// Interleaving is safe because both writers emit exactly one complete line per `write_all` and
4789/// flush it, and `O_APPEND` makes each such write atomic against the other's.
4790pub struct ResourceTraceAppender {
4791    /// Async rather than a `std::sync::Mutex`: this is held across an `await` on the write.
4792    file: tokio::sync::Mutex<File>,
4793    session_id: String,
4794    /// The session node every line this appender writes hangs off — [`TraceWriter`]'s own
4795    /// [`TraceWriter::session_event_id`], handed over at open time. The plane has no turn or
4796    /// task of its own: a read served after `session_end` belongs to the launch, not to
4797    /// whatever task happened to run last.
4798    session_event_id: String,
4799}
4800
4801impl ResourceTraceAppender {
4802    pub async fn open(
4803        records_root: &WorkdirRoot,
4804        session_id: String,
4805        session_event_id: String,
4806    ) -> std::io::Result<Self> {
4807        let file = File::from_std(records_root.open_append(TRACE_FILE_NAME, DEFAULT_FILE_MODE)?);
4808        Ok(Self {
4809            file: tokio::sync::Mutex::new(file),
4810            session_id,
4811            session_event_id,
4812        })
4813    }
4814
4815    /// Records one `list`. Failures are swallowed: a trace that cannot be written must not turn
4816    /// a served read into an error, and the alternative — refusing the read — would let anyone
4817    /// who can fill the disk take the plane down.
4818    #[allow(clippy::too_many_arguments)]
4819    pub(crate) async fn write_resource_list(
4820        &self,
4821        root: &str,
4822        entry_count: usize,
4823        total_bytes: u64,
4824        generation: u64,
4825        containment_achieved: ContainmentClass,
4826        outcome: &str,
4827        reason: Option<String>,
4828    ) {
4829        let event = ResourceListEvent {
4830            event_type: "resource_list",
4831            event_id: new_event_id(),
4832            parent_id: Some(self.session_event_id.clone()),
4833            session_id: self.session_id.clone(),
4834            timestamp: timestamp_ms(),
4835            root: root.to_string(),
4836            entry_count,
4837            total_bytes,
4838            generation,
4839            containment_achieved,
4840            outcome: outcome.to_string(),
4841            reason,
4842        };
4843        self.append(&event).await;
4844    }
4845
4846    /// Records one `read`, served or refused. `bytes` and `sha256` are `None` on every
4847    /// non-`ok` outcome — a refusal must not carry a hash of something it did not serve.
4848    #[allow(clippy::too_many_arguments)]
4849    pub(crate) async fn write_resource_read(
4850        &self,
4851        path: &str,
4852        outcome: &str,
4853        bytes: Option<u64>,
4854        sha256: Option<String>,
4855        generation: u64,
4856        containment_achieved: ContainmentClass,
4857        reason: Option<String>,
4858    ) {
4859        let event = ResourceReadEvent {
4860            event_type: "resource_read",
4861            event_id: new_event_id(),
4862            parent_id: Some(self.session_event_id.clone()),
4863            session_id: self.session_id.clone(),
4864            timestamp: timestamp_ms(),
4865            path: path.to_string(),
4866            outcome: outcome.to_string(),
4867            bytes,
4868            sha256,
4869            generation,
4870            containment_achieved,
4871            reason,
4872        };
4873        self.append(&event).await;
4874    }
4875
4876    /// Records `formation_ended`, hung off the session node. Failures are swallowed: a member
4877    /// whose trace cannot be written still winds down.
4878    pub(crate) async fn write_formation_ended(&self, formation_id: &crate::FormationId) {
4879        let event = FormationEndedEvent {
4880            event_type: "formation_ended",
4881            event_id: new_event_id(),
4882            parent_id: Some(self.session_event_id.clone()),
4883            session_id: self.session_id.clone(),
4884            timestamp: timestamp_ms(),
4885            formation_id: formation_id.as_str().to_string(),
4886        };
4887        self.append(&event).await;
4888    }
4889
4890    /// Records one `session-status` call, hung off the session node. Failures are swallowed: the
4891    /// call is answered whether or not its record lands.
4892    pub(crate) async fn write_session_status(
4893        &self,
4894        task_id: Option<String>,
4895        status: &crate::session_status::StatusReport,
4896    ) {
4897        let event = SessionStatusEvent {
4898            event_type: "session_status",
4899            event_id: new_event_id(),
4900            parent_id: Some(self.session_event_id.clone()),
4901            session_id: self.session_id.clone(),
4902            timestamp: timestamp_ms(),
4903            task_id,
4904            status,
4905        };
4906        self.append(&event).await;
4907    }
4908
4909    /// Records one answered `background-command` call, hung off the session node. Failures are
4910    /// swallowed: the call is answered whether or not its record lands.
4911    #[allow(clippy::too_many_arguments)]
4912    pub(crate) async fn write_background_command(
4913        &self,
4914        task_id: Option<String>,
4915        work_id: &str,
4916        operation: &'static str,
4917        state: &'static str,
4918        exit_code: Option<i32>,
4919        waited_ms: Option<u64>,
4920        output_bytes: u64,
4921    ) {
4922        let event = BackgroundCommandEvent {
4923            event_type: "background_command",
4924            event_id: new_event_id(),
4925            parent_id: Some(self.session_event_id.clone()),
4926            session_id: self.session_id.clone(),
4927            timestamp: timestamp_ms(),
4928            task_id,
4929            work_id,
4930            operation,
4931            state,
4932            exit_code,
4933            waited_ms,
4934            output_bytes,
4935        };
4936        self.append(&event).await;
4937    }
4938
4939    /// Records a detached command's completion that a `background-command` call just returned to
4940    /// the agent, hung off the session node with no `completion_task_id`. Written after that call's
4941    /// `background_command` line. Failures are swallowed, as that line's are.
4942    pub(crate) async fn write_shell_completed(
4943        &self,
4944        completion: &crate::detached::DetachedCompletion,
4945    ) {
4946        let event = ShellCompletedEvent::new(
4947            self.session_id.clone(),
4948            Some(self.session_event_id.clone()),
4949            completion,
4950            crate::detached::Delivery::BackgroundCommand,
4951            None,
4952        );
4953        self.append(&event).await;
4954    }
4955
4956    /// Records the deadline notice `tool_name`'s result carried, hung off the session node.
4957    pub(crate) async fn write_deadline_notice(
4958        &self,
4959        task_id: Option<String>,
4960        tool_name: &str,
4961        notice: &crate::session_status::DeadlineNotice,
4962    ) {
4963        let event = DeadlineNoticeEvent {
4964            event_type: "deadline_notice",
4965            event_id: new_event_id(),
4966            parent_id: Some(self.session_event_id.clone()),
4967            session_id: self.session_id.clone(),
4968            timestamp: timestamp_ms(),
4969            task_id,
4970            tool_name,
4971            deadline_at: &notice.at,
4972            left_secs: notice.left_secs,
4973            notice_secs: notice.notice_secs,
4974        };
4975        self.append(&event).await;
4976    }
4977
4978    /// Records `deadline_reached`, hung off the session node. Failures are swallowed: a session
4979    /// whose trace cannot be written still ends at its deadline.
4980    pub(crate) async fn write_deadline_reached(
4981        &self,
4982        deadline: &crate::session_status::SessionDeadline,
4983    ) {
4984        let event = DeadlineReachedEvent {
4985            event_type: "deadline_reached",
4986            event_id: new_event_id(),
4987            parent_id: Some(self.session_event_id.clone()),
4988            session_id: self.session_id.clone(),
4989            timestamp: timestamp_ms(),
4990            deadline_at: deadline.at_rfc3339(),
4991            deadline_secs: deadline.secs(),
4992            source: deadline.source().as_str(),
4993        };
4994        self.append(&event).await;
4995    }
4996
4997    /// Records `spawner_ended`, hung off the session node, naming the session that spawned this
4998    /// one and the delegation it was spawned under. Failures are swallowed: a child whose trace
4999    /// cannot be written still winds down.
5000    pub(crate) async fn write_spawner_ended(
5001        &self,
5002        spawned_by: Option<&str>,
5003        delegation_id: Option<&str>,
5004    ) {
5005        let event = SpawnerEndedEvent {
5006            event_type: "spawner_ended",
5007            event_id: new_event_id(),
5008            parent_id: Some(self.session_event_id.clone()),
5009            session_id: self.session_id.clone(),
5010            timestamp: timestamp_ms(),
5011            spawned_by: spawned_by.map(str::to_string),
5012            delegation_id: delegation_id.map(str::to_string),
5013        };
5014        self.append(&event).await;
5015    }
5016
5017    /// Records one mint. `handle_id` and `expires_at_ms` are `None` on every non-`ok` outcome —
5018    /// a refused mint produced no token, and an audit record must not imply one exists.
5019    #[allow(clippy::too_many_arguments)]
5020    pub(crate) async fn write_peer_handle_mint(
5021        &self,
5022        handle_id: Option<String>,
5023        path: &str,
5024        audience: &str,
5025        expires_at_ms: Option<u64>,
5026        outcome: &str,
5027        reason: Option<String>,
5028    ) {
5029        let event = PeerHandleMintEvent {
5030            event_type: "peer_handle_mint",
5031            event_id: new_event_id(),
5032            parent_id: Some(self.session_event_id.clone()),
5033            session_id: self.session_id.clone(),
5034            timestamp: timestamp_ms(),
5035            handle_id,
5036            path: path.to_string(),
5037            audience: audience.to_string(),
5038            expires_at_ms,
5039            outcome: outcome.to_string(),
5040            reason,
5041        };
5042        self.append(&event).await;
5043    }
5044
5045    /// Records one redeem, served or refused. The token itself is never written — only its
5046    /// `handle_id`.
5047    #[allow(clippy::too_many_arguments)]
5048    pub(crate) async fn write_peer_handle_redeem(
5049        &self,
5050        handle_id: &str,
5051        path: Option<String>,
5052        generation: u64,
5053        audience_asserted: Option<String>,
5054        bytes: Option<u64>,
5055        sha256: Option<String>,
5056        outcome: &str,
5057        reason: Option<String>,
5058    ) {
5059        let event = PeerHandleRedeemEvent {
5060            event_type: "peer_handle_redeem",
5061            event_id: new_event_id(),
5062            parent_id: Some(self.session_event_id.clone()),
5063            session_id: self.session_id.clone(),
5064            timestamp: timestamp_ms(),
5065            handle_id: handle_id.to_string(),
5066            path,
5067            generation,
5068            audience_asserted,
5069            bytes,
5070            sha256,
5071            outcome: outcome.to_string(),
5072            reason,
5073        };
5074        self.append(&event).await;
5075    }
5076
5077    /// Records one fetch on the ingesting side, served or refused.
5078    #[allow(clippy::too_many_arguments)]
5079    pub(crate) async fn write_peer_file_fetch(
5080        &self,
5081        peer: &str,
5082        handle_id: &str,
5083        stored_path: Option<String>,
5084        bytes: Option<u64>,
5085        sha256: Option<String>,
5086        outcome: &str,
5087        reason: Option<String>,
5088    ) {
5089        let event = PeerFileFetchEvent {
5090            event_type: "peer_file_fetch",
5091            event_id: new_event_id(),
5092            parent_id: Some(self.session_event_id.clone()),
5093            session_id: self.session_id.clone(),
5094            timestamp: timestamp_ms(),
5095            peer: peer.to_string(),
5096            handle_id: handle_id.to_string(),
5097            stored_path,
5098            bytes,
5099            sha256,
5100            outcome: outcome.to_string(),
5101            reason,
5102        };
5103        self.append(&event).await;
5104    }
5105
5106    /// Records one launched child, while its delegation is still running.
5107    ///
5108    /// Every field is known by the time the child has reported its session id, so none is
5109    /// optional: a delegation with nothing to say here was never launched and writes no line.
5110    #[allow(clippy::too_many_arguments)]
5111    pub(crate) async fn write_delegation_start(
5112        &self,
5113        delegation_id: &str,
5114        capsule: &str,
5115        version: &str,
5116        child_session_id: &str,
5117        child_workdir: &str,
5118        child_records_dir: &str,
5119        workdir_snapshot: Option<&crate::workdir_snapshot::WorkdirSnapshot>,
5120    ) {
5121        let event = DelegationStartEvent::new(
5122            &self.session_id,
5123            &self.session_event_id,
5124            delegation_id,
5125            capsule,
5126            version,
5127            child_session_id,
5128            child_workdir,
5129            child_records_dir,
5130            workdir_snapshot,
5131        );
5132        self.append(&event).await;
5133    }
5134
5135    /// Records one delegation ending. `delegation_id` and `child_session_id` are `None` when no
5136    /// child was launched — a delegation that never started names no id, and an audit record must
5137    /// not imply one exists.
5138    #[allow(clippy::too_many_arguments)]
5139    pub(crate) async fn write_delegation(
5140        &self,
5141        capsule: &str,
5142        version: &str,
5143        delegation_id: Option<String>,
5144        child_session_id: Option<String>,
5145        duration_ms: u64,
5146        outcome: &str,
5147        reason: Option<String>,
5148    ) {
5149        let event = DelegationEvent::new(
5150            &self.session_id,
5151            &self.session_event_id,
5152            capsule,
5153            version,
5154            delegation_id,
5155            child_session_id,
5156            duration_ms,
5157            outcome,
5158            reason,
5159        );
5160        self.append(&event).await;
5161    }
5162
5163    /// Records a `call-member` call whose callee now holds the task.
5164    pub(crate) async fn write_member_call_start(
5165        &self,
5166        task_id: &str,
5167        call_id: &str,
5168        member: &str,
5169        member_task_id: &str,
5170    ) {
5171        let event = self.member_call_start(task_id, call_id, member, member_task_id);
5172        self.append(&event).await;
5173    }
5174
5175    /// [`Self::write_member_call_start`] when `admit`, run with the trace held, returns `true`;
5176    /// returns what `admit` did. No other line lands between `admit` and this one.
5177    pub(crate) async fn write_member_call_start_if(
5178        &self,
5179        task_id: &str,
5180        call_id: &str,
5181        member: &str,
5182        member_task_id: &str,
5183        admit: impl FnOnce() -> bool,
5184    ) -> bool {
5185        let event = self.member_call_start(task_id, call_id, member, member_task_id);
5186        self.append_if(&event, admit).await
5187    }
5188
5189    fn member_call_start(
5190        &self,
5191        task_id: &str,
5192        call_id: &str,
5193        member: &str,
5194        member_task_id: &str,
5195    ) -> MemberCallStartEvent {
5196        MemberCallStartEvent {
5197            event_type: "member_call_start",
5198            event_id: new_event_id(),
5199            parent_id: Some(self.session_event_id.clone()),
5200            session_id: self.session_id.clone(),
5201            timestamp: timestamp_ms(),
5202            task_id: task_id.to_string(),
5203            call_id: call_id.to_string(),
5204            member: member.to_string(),
5205            member_task_id: member_task_id.to_string(),
5206        }
5207    }
5208
5209    /// Records the `offer`th offer of a `call-member` call turned away busy, `waited_ms` after the
5210    /// call was made, with the door's status `message`.
5211    pub(crate) async fn write_member_call_busy(
5212        &self,
5213        task_id: &str,
5214        call_id: &str,
5215        member: &str,
5216        offer: u32,
5217        waited_ms: u64,
5218        message: &str,
5219    ) {
5220        let event = MemberCallBusyEvent {
5221            event_type: "member_call_busy",
5222            event_id: new_event_id(),
5223            parent_id: Some(self.session_event_id.clone()),
5224            session_id: self.session_id.clone(),
5225            timestamp: timestamp_ms(),
5226            task_id: task_id.to_string(),
5227            call_id: call_id.to_string(),
5228            member: member.to_string(),
5229            offer,
5230            waited_ms,
5231            message: message.to_string(),
5232        };
5233        self.append(&event).await;
5234    }
5235
5236    /// Records one `call-member` call's ending, and whether its outcome reached the calling task.
5237    pub(crate) async fn write_member_call(
5238        &self,
5239        task_id: &str,
5240        outcome: &crate::member_call::MemberCallOutcome,
5241        delivered: bool,
5242    ) {
5243        let event = MemberCallEvent {
5244            event_type: "member_call",
5245            event_id: new_event_id(),
5246            parent_id: Some(self.session_event_id.clone()),
5247            session_id: self.session_id.clone(),
5248            timestamp: timestamp_ms(),
5249            task_id: task_id.to_string(),
5250            call_id: outcome.call_id.clone(),
5251            member: outcome.member.clone(),
5252            member_task_id: outcome.member_task_id.clone(),
5253            status: outcome.status.as_str().to_string(),
5254            duration_ms: outcome.duration_ms,
5255            output: outcome.output.clone(),
5256            truncated: outcome.truncated,
5257            delivered,
5258            no_answer_below: outcome
5259                .below
5260                .iter()
5261                .map(|(member, status)| NoAnswerBelow {
5262                    member: member.clone(),
5263                    status: status.as_str().to_string(),
5264                })
5265                .collect(),
5266        };
5267        self.append(&event).await;
5268    }
5269
5270    /// Records one change to the session's inference credential. Carries where the credential
5271    /// comes from and what happened to it, never the value.
5272    pub(crate) async fn write_inference_credential(
5273        &self,
5274        source: &'static str,
5275        credential: Option<&str>,
5276        change: CredentialChange,
5277    ) {
5278        let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
5279        let event = InferenceCredentialEvent {
5280            event_type: "inference_credential",
5281            event_id: new_event_id(),
5282            parent_id: Some(self.session_event_id.clone()),
5283            session_id: self.session_id.clone(),
5284            timestamp: timestamp_ms(),
5285            source,
5286            credential,
5287            change: change_name,
5288            trigger,
5289            status,
5290            retried,
5291            reason,
5292        };
5293        self.append(&event).await;
5294    }
5295
5296    /// Writes a `gateway_credential` event for `artifact`'s credential: the fields
5297    /// [`Self::write_inference_credential`] writes, plus `artifact`.
5298    pub(crate) async fn write_gateway_credential(
5299        &self,
5300        artifact: &str,
5301        source: &'static str,
5302        credential: Option<&str>,
5303        change: CredentialChange,
5304    ) {
5305        let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
5306        let event = GatewayCredentialEvent {
5307            event_type: "gateway_credential",
5308            event_id: new_event_id(),
5309            parent_id: Some(self.session_event_id.clone()),
5310            session_id: self.session_id.clone(),
5311            timestamp: timestamp_ms(),
5312            artifact,
5313            source,
5314            credential,
5315            change: change_name,
5316            trigger,
5317            status,
5318            retried,
5319            reason,
5320        };
5321        self.append(&event).await;
5322    }
5323
5324    /// Records one accepted control-plane change, under the `event_id` the caller minted.
5325    pub(crate) async fn write_control_change(&self, change: ControlChange<'_>) {
5326        let event = ControlChangeEvent {
5327            event_type: "control_change",
5328            event_id: change.event_id,
5329            parent_id: Some(self.session_event_id.clone()),
5330            session_id: self.session_id.clone(),
5331            timestamp: timestamp_ms(),
5332            principal: change.principal.as_str(),
5333            token_id: change.token_id,
5334            kind: change.kind,
5335            name: change.name,
5336            action: change.action,
5337            previous: change.previous,
5338            value: change.value,
5339            applies_from: change.applies_from,
5340            replaced: change.replaced,
5341        };
5342        self.append(&event).await;
5343    }
5344
5345    /// Records one refused change. `kind`, `name` and `token_id` are `None` for a controller's
5346    /// request that did not authenticate.
5347    pub(crate) async fn write_control_refused(
5348        &self,
5349        principal: crate::control_plane::Principal,
5350        status: u16,
5351        reason: &'static str,
5352        kind: Option<&'static str>,
5353        name: Option<&str>,
5354        token_id: Option<&str>,
5355    ) {
5356        let event = ControlRefusedEvent {
5357            event_type: "control_refused",
5358            event_id: new_event_id(),
5359            parent_id: Some(self.session_event_id.clone()),
5360            session_id: self.session_id.clone(),
5361            timestamp: timestamp_ms(),
5362            principal: principal.as_str(),
5363            status,
5364            reason,
5365            kind,
5366            name,
5367            token_id,
5368        };
5369        self.append(&event).await;
5370    }
5371
5372    async fn append(&self, event: &impl Serialize) {
5373        self.append_if(event, || true).await;
5374    }
5375
5376    /// [`Self::append`] when `admit`, run with the trace held, returns `true`; returns what
5377    /// `admit` did. No other line lands between `admit` and this one.
5378    async fn append_if(&self, event: &impl Serialize, admit: impl FnOnce() -> bool) -> bool {
5379        let Ok(mut line) = serde_json::to_string(event) else {
5380            return admit();
5381        };
5382        line.push('\n');
5383        let mut file = self.file.lock().await;
5384        if !admit() {
5385            return false;
5386        }
5387        if file.write_all(line.as_bytes()).await.is_ok() {
5388            let _ = file.flush().await;
5389        }
5390        true
5391    }
5392}
5393
5394// -- Prior-session appender ---------------------------------------------------
5395
5396/// An `O_APPEND` handle to a *previous* session's `trace.jsonl`, opened by a resuming launch to
5397/// account for demoted commands that session left unreported.
5398///
5399/// The marker goes in the file that holds the unmatched `shell_detached` line rather than in the
5400/// resuming session's own trace, which keeps clearing O(1) instead of a scan of every sibling
5401/// session, and means [`crate::retention::prune_sessions`] removes a marker and the work it
5402/// accounts for together.
5403///
5404/// Writing to another session's trace is safe because that session's writer is dead — that is the
5405/// precondition for there being anything unaccounted — and because this obeys the same rule
5406/// [`ResourceTraceAppender`] does: one complete line per `write_all`, then flush, which `O_APPEND`
5407/// makes atomic against any other writer.
5408pub(crate) struct PriorSessionTraceAppender {
5409    file: File,
5410    /// The prior session's id, which is what its own lines carry.
5411    session_id: String,
5412    /// That session's `session_start` node, read back from the same file.
5413    session_event_id: Option<String>,
5414    /// Whether the file's last record is unterminated, which a writer killed mid-`write_all`
5415    /// leaves behind. The first appended line opens with a newline when it is, so the marker is
5416    /// a line of its own rather than spliced onto the torn one.
5417    terminate_torn_tail: bool,
5418}
5419
5420impl PriorSessionTraceAppender {
5421    /// `trace` is the resumed trace's file name in `trace_dir`, the directory
5422    /// [`crate::ResumeRequest::trace_dir`] names. Errors when it cannot be read or opened for
5423    /// appending, which the caller reports rather than reporting work it could not mark.
5424    pub(crate) async fn open(
5425        trace_dir: &WorkdirRoot,
5426        trace: &str,
5427        session_id: String,
5428        session_event_id: Option<String>,
5429    ) -> std::io::Result<Self> {
5430        let contents = trace_dir
5431            .read_file(trace, u64::MAX)?
5432            .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))?;
5433        // An empty file counts as terminated: there is no record for an appended line to run into.
5434        let terminate_torn_tail = contents.last().is_some_and(|last| *last != b'\n');
5435        let file = File::from_std(trace_dir.open_append(trace, DEFAULT_FILE_MODE)?);
5436        Ok(Self {
5437            file,
5438            session_id,
5439            session_event_id,
5440            terminate_torn_tail,
5441        })
5442    }
5443
5444    /// Records one demoted command as lost, and reports whether the line landed.
5445    ///
5446    /// The result is not swallowed: a work id whose marker did not land must be dropped from the
5447    /// report rather than reported unmarked, because an unmarked work id is reported again by
5448    /// every later resume of the same session.
5449    pub(crate) async fn write_shell_lost(
5450        &mut self,
5451        work: &crate::detached::LostWork,
5452        reconciled_by_session: &str,
5453        reconciled_task_id: &str,
5454    ) -> std::io::Result<()> {
5455        let event = ShellLostEvent {
5456            event_type: "shell_lost",
5457            event_id: new_event_id(),
5458            parent_id: self.session_event_id.clone(),
5459            session_id: self.session_id.clone(),
5460            timestamp: timestamp_ms(),
5461            work_id: work.work_id.clone(),
5462            binary: work.binary.clone(),
5463            command: work.command.clone(),
5464            detached_at_ms: work.detached_at_ms,
5465            reconciled_by_session: reconciled_by_session.to_string(),
5466            reconciled_task_id: reconciled_task_id.to_string(),
5467        };
5468        let mut line = if self.terminate_torn_tail {
5469            "\n".to_string()
5470        } else {
5471            String::new()
5472        };
5473        line.push_str(&serde_json::to_string(&event).map_err(std::io::Error::other)?);
5474        line.push('\n');
5475        self.file.write_all(line.as_bytes()).await?;
5476        self.file.flush().await?;
5477        self.terminate_torn_tail = false;
5478        Ok(())
5479    }
5480}
5481
5482pub(crate) fn timestamp_ms() -> u64 {
5483    SystemTime::now()
5484        .duration_since(UNIX_EPOCH)
5485        .unwrap_or_default()
5486        .as_millis() as u64
5487}
5488
5489// ── Plan-scheduler events ────────────────────────────────────────────────────
5490
5491/// One step of a plan's DAG as authored, written into `plan_start`.
5492///
5493/// The shape is recorded once, up front, so the run stays legible for a step that never ran:
5494/// the trace names every step the plan declared whether or not the scheduler reached it.
5495#[derive(Serialize, Clone)]
5496pub(crate) struct PlanStepShape {
5497    pub(crate) step_id: String,
5498    /// `"tool"`, `"shell"` or `"capsule"`; `"unknown"` for a step whose kind cannot be inferred,
5499    /// which is a step the plan validator refuses.
5500    pub(crate) kind: &'static str,
5501    pub(crate) depends_on: Vec<String>,
5502    /// Whether the step carries an `if`, and so may settle without ever being dispatched.
5503    pub(crate) has_condition: bool,
5504}
5505
5506/// The plan's structure, written once before any step runs. Its `event_id` is the plan node
5507/// every other line of the run hangs off.
5508#[derive(Serialize)]
5509struct PlanStartEvent {
5510    event_type: &'static str,
5511    event_id: String,
5512    parent_id: Option<String>,
5513    session_id: String,
5514    timestamp: u64,
5515    plan_id: String,
5516    step_count: usize,
5517    steps: Vec<PlanStepShape>,
5518}
5519
5520/// One dispatched step, written as the scheduler hands it to a worker.
5521///
5522/// A step that settled without ever being dispatched — an `if` that evaluated false, a
5523/// dependency that never resolved — writes no line here, only a terminal `plan_step`. Joins to
5524/// that line on `(plan_id, step_id)`.
5525#[derive(Serialize)]
5526struct PlanStepStartEvent {
5527    event_type: &'static str,
5528    event_id: String,
5529    parent_id: Option<String>,
5530    session_id: String,
5531    timestamp: u64,
5532    plan_id: String,
5533    step_id: String,
5534    kind: &'static str,
5535    depends_on: Vec<String>,
5536}
5537
5538/// A settled step, written by the scheduler loop after the step's `on_error` policy has been
5539/// applied — which is why the worker thread does not write it: a step that failed under
5540/// `on_error: skip` settles as skipped in the returned report and must read as skipped here.
5541#[derive(Serialize)]
5542struct PlanStepEvent {
5543    event_type: &'static str,
5544    event_id: String,
5545    parent_id: Option<String>,
5546    session_id: String,
5547    timestamp: u64,
5548    plan_id: String,
5549    step_id: String,
5550    kind: &'static str,
5551    status: &'static str,
5552    attempts: u32,
5553    duration_ms: u64,
5554    #[serde(skip_serializing_if = "Option::is_none")]
5555    error: Option<String>,
5556    /// The interpolated input the step dispatched, with peer handle tokens redacted. Tool steps
5557    /// only — a shell step's command and a capsule step's task text are not carried here.
5558    #[serde(skip_serializing_if = "Option::is_none")]
5559    input: Option<Value>,
5560    /// The tool's self-declared `state_effect` for this step, lifted verbatim from
5561    /// `tool-result.metadata`. Absent when the tool declared nothing. Feeds the same
5562    /// redundant-call analysis `tool_call.state_effect` does.
5563    #[serde(skip_serializing_if = "Option::is_none")]
5564    state_effect: Option<String>,
5565    /// The resource this step addressed, as the tool declared it. Opaque and never parsed here.
5566    #[serde(skip_serializing_if = "Option::is_none")]
5567    resource_id: Option<String>,
5568}
5569
5570/// The run's summary, written once as `plan::execute` returns, whatever ended it.
5571#[derive(Serialize)]
5572struct PlanEndEvent {
5573    event_type: &'static str,
5574    event_id: String,
5575    parent_id: Option<String>,
5576    session_id: String,
5577    timestamp: u64,
5578    plan_id: String,
5579    outcome: &'static str,
5580    #[serde(skip_serializing_if = "Option::is_none")]
5581    failed_step: Option<String>,
5582    steps_total: usize,
5583    steps_succeeded: usize,
5584    steps_failed: usize,
5585    steps_skipped: usize,
5586    duration_ms: u64,
5587    /// Why the run ended when the reason was not a step's own failure: a plan that would not
5588    /// parse, a cgroup scope the host refused, a DAG with nothing left to run.
5589    #[serde(skip_serializing_if = "Option::is_none")]
5590    reason: Option<String>,
5591}
5592
5593/// Everything the scheduler loop knows about a settled step, as one argument.
5594pub(crate) struct PlanStepRecord {
5595    pub(crate) plan_id: String,
5596    pub(crate) step_id: String,
5597    pub(crate) kind: &'static str,
5598    /// `"success"`, `"failed"` or `"skipped"` — the status the returned `ExecutionReport` carries
5599    /// for this step, so the two can never disagree.
5600    pub(crate) status: &'static str,
5601    /// How many times the step was dispatched. `0` for a step that settled without dispatch.
5602    pub(crate) attempts: u32,
5603    pub(crate) duration_ms: u64,
5604    pub(crate) error: Option<String>,
5605    pub(crate) input: Option<Value>,
5606    pub(crate) state_effect: Option<String>,
5607    pub(crate) resource_id: Option<String>,
5608}
5609
5610/// Everything the scheduler knows about a finished run, as one argument.
5611pub(crate) struct PlanEndRecord {
5612    pub(crate) plan_id: String,
5613    pub(crate) outcome: &'static str,
5614    pub(crate) failed_step: Option<String>,
5615    pub(crate) steps_total: usize,
5616    pub(crate) steps_succeeded: usize,
5617    pub(crate) steps_failed: usize,
5618    pub(crate) steps_skipped: usize,
5619    pub(crate) duration_ms: u64,
5620    pub(crate) reason: Option<String>,
5621}
5622
5623/// A third `O_APPEND` handle to the session's `trace.jsonl`, held by a plan run.
5624///
5625/// Synchronous where [`ResourceTraceAppender`] is async, because [`crate::plan::execute`] is: it
5626/// runs its ready set on plain threads inside a `thread::scope`, so there is no runtime to await
5627/// on and no `await` for a lock to be held across. Interleaving with [`TraceWriter`] and with
5628/// the resource plane is safe on the same terms they interleave with each other — one complete
5629/// line per `write_all`, then flush, which `O_APPEND` makes atomic against the other writers.
5630///
5631/// Handing one of these to a [`crate::plan::SchedulerContext`] is the whole of the opt-in: a
5632/// context carrying `None` opens no file and writes no line.
5633pub struct PlanTraceAppender {
5634    file: std::sync::Mutex<std::fs::File>,
5635    session_id: String,
5636    /// The session node a run's `plan_start` hangs off — [`TraceWriter`]'s own
5637    /// `session_start`, handed over at open time so an appended line names a real parent.
5638    session_event_id: String,
5639}
5640
5641impl PlanTraceAppender {
5642    pub fn open(
5643        records_root: &WorkdirRoot,
5644        session_id: String,
5645        session_event_id: String,
5646    ) -> std::io::Result<Self> {
5647        let file = records_root.open_append(TRACE_FILE_NAME, DEFAULT_FILE_MODE)?;
5648        Ok(Self {
5649            file: std::sync::Mutex::new(file),
5650            session_id,
5651            session_event_id,
5652        })
5653    }
5654
5655    /// Records the plan's DAG and returns the plan node every later line of this run hangs off.
5656    pub(crate) fn write_plan_start(&self, plan_id: &str, steps: Vec<PlanStepShape>) -> String {
5657        let event_id = new_event_id();
5658        let event = PlanStartEvent {
5659            event_type: "plan_start",
5660            event_id: event_id.clone(),
5661            parent_id: Some(self.session_event_id.clone()),
5662            session_id: self.session_id.clone(),
5663            timestamp: timestamp_ms(),
5664            plan_id: plan_id.to_string(),
5665            step_count: steps.len(),
5666            steps,
5667        };
5668        self.append(&event);
5669        event_id
5670    }
5671
5672    pub(crate) fn write_plan_step_start(
5673        &self,
5674        plan_node: &str,
5675        plan_id: &str,
5676        step_id: &str,
5677        kind: &'static str,
5678        depends_on: Vec<String>,
5679    ) {
5680        let event = PlanStepStartEvent {
5681            event_type: "plan_step_start",
5682            event_id: new_event_id(),
5683            parent_id: Some(plan_node.to_string()),
5684            session_id: self.session_id.clone(),
5685            timestamp: timestamp_ms(),
5686            plan_id: plan_id.to_string(),
5687            step_id: step_id.to_string(),
5688            kind,
5689            depends_on,
5690        };
5691        self.append(&event);
5692    }
5693
5694    pub(crate) fn write_plan_step(&self, plan_node: &str, record: PlanStepRecord) {
5695        // On the same terms `TraceWriter::write_tool_call` redacts a tool call's input: a peer
5696        // handle is a credential and this file is durable.
5697        let input = record.input.map(|mut value| {
5698            crate::peer_handoff::redact_handles_in_json(&mut value);
5699            value
5700        });
5701        let event = PlanStepEvent {
5702            event_type: "plan_step",
5703            event_id: new_event_id(),
5704            parent_id: Some(plan_node.to_string()),
5705            session_id: self.session_id.clone(),
5706            timestamp: timestamp_ms(),
5707            plan_id: record.plan_id,
5708            step_id: record.step_id,
5709            kind: record.kind,
5710            status: record.status,
5711            attempts: record.attempts,
5712            duration_ms: record.duration_ms,
5713            error: record.error,
5714            input,
5715            state_effect: record.state_effect,
5716            resource_id: record.resource_id,
5717        };
5718        self.append(&event);
5719    }
5720
5721    /// Records the run's summary. `plan_node` is `None` for a run that never had one — a plan
5722    /// file that would not parse describes no DAG — and the line hangs off the session instead.
5723    pub(crate) fn write_plan_end(&self, plan_node: Option<&str>, record: PlanEndRecord) {
5724        let event = PlanEndEvent {
5725            event_type: "plan_end",
5726            event_id: new_event_id(),
5727            parent_id: Some(
5728                plan_node
5729                    .map(str::to_string)
5730                    .unwrap_or_else(|| self.session_event_id.clone()),
5731            ),
5732            session_id: self.session_id.clone(),
5733            timestamp: timestamp_ms(),
5734            plan_id: record.plan_id,
5735            outcome: record.outcome,
5736            failed_step: record.failed_step,
5737            steps_total: record.steps_total,
5738            steps_succeeded: record.steps_succeeded,
5739            steps_failed: record.steps_failed,
5740            steps_skipped: record.steps_skipped,
5741            duration_ms: record.duration_ms,
5742            reason: record.reason,
5743        };
5744        self.append(&event);
5745    }
5746
5747    /// Records one child a `capsule` step launched, while its delegation is still running.
5748    ///
5749    /// The synchronous twin of [`ResourceTraceAppender::write_delegation_start`]. Both go through
5750    /// [`DelegationStartEvent::new`], so the two lines are identical but for their ids and
5751    /// clocks — held by there being one constructor rather than by a test.
5752    #[allow(clippy::too_many_arguments)]
5753    pub(crate) fn write_delegation_start(
5754        &self,
5755        delegation_id: &str,
5756        capsule: &str,
5757        version: &str,
5758        child_session_id: &str,
5759        child_workdir: &str,
5760        child_records_dir: &str,
5761        workdir_snapshot: Option<&crate::workdir_snapshot::WorkdirSnapshot>,
5762    ) {
5763        let event = DelegationStartEvent::new(
5764            &self.session_id,
5765            &self.session_event_id,
5766            delegation_id,
5767            capsule,
5768            version,
5769            child_session_id,
5770            child_workdir,
5771            child_records_dir,
5772            workdir_snapshot,
5773        );
5774        self.append(&event);
5775    }
5776
5777    /// Records one `capsule` step's delegation ending. `delegation_id` and `child_session_id` are
5778    /// `None` when no child was launched, on the same terms
5779    /// [`ResourceTraceAppender::write_delegation`] passes them.
5780    ///
5781    /// One pair per *attempt*: a step with retries delegates once per attempt, so it writes one
5782    /// of these per attempt. Two `failed` lines and a `completed` one is what happened, not a
5783    /// duplicate of one thing.
5784    #[allow(clippy::too_many_arguments)]
5785    pub(crate) fn write_delegation(
5786        &self,
5787        capsule: &str,
5788        version: &str,
5789        delegation_id: Option<String>,
5790        child_session_id: Option<String>,
5791        duration_ms: u64,
5792        outcome: &str,
5793        reason: Option<String>,
5794    ) {
5795        let event = DelegationEvent::new(
5796            &self.session_id,
5797            &self.session_event_id,
5798            capsule,
5799            version,
5800            delegation_id,
5801            child_session_id,
5802            duration_ms,
5803            outcome,
5804            reason,
5805        );
5806        self.append(&event);
5807    }
5808
5809    /// Writes one complete line and flushes it. Every failure is swallowed, exactly as
5810    /// [`ResourceTraceAppender::append`] swallows one: a trace that cannot be written must never
5811    /// turn a step that ran into a step that failed.
5812    fn append(&self, event: &impl Serialize) {
5813        use std::io::Write as _;
5814
5815        let Ok(mut line) = serde_json::to_string(event) else {
5816            return;
5817        };
5818        line.push('\n');
5819        let Ok(mut file) = self.file.lock() else {
5820            return;
5821        };
5822        if file.write_all(line.as_bytes()).is_ok() {
5823            let _ = file.flush();
5824        }
5825    }
5826}
5827
5828// ── Unit tests ────────────────────────────────────────────────────────────────
5829
5830#[cfg(test)]
5831mod tests {
5832    use super::*;
5833    use crate::{
5834        sandbox::EnforcementTier, sealed::UsernsGrant, trace_blobs::BLOB_DIR_NAME,
5835        types::CapabilityPolicy,
5836    };
5837    use murmur_artifact::{ArtifactRuntime, InterpreterRuntimeDir, InterpreterRuntimeGrant};
5838    use serde_json::Value;
5839    use std::path::Path;
5840
5841    use crate::origin::TaskOrigin;
5842
5843    /// The class an A2A task carrying no origin claim resolves to.
5844    fn event_provenance() -> TaskProvenance {
5845        TaskProvenance::derive(TaskOrigin::Event, None)
5846    }
5847
5848    /// A [`ScopeReport`] built the way production builds one — through
5849    /// `containment::report_for` with an *explicit* tier, never a live probe, so these
5850    /// tests assert the same values on a Landlock-capable host and a laptop that has none.
5851    fn report_for(
5852        declared: ContainmentClass,
5853        tier: EnforcementTier,
5854        workdir_exec: bool,
5855    ) -> ScopeReport {
5856        crate::containment::report_for(
5857            &CapabilityPolicy {
5858                workdir_exec_allowed: workdir_exec,
5859                ..CapabilityPolicy::default()
5860            },
5861            declared,
5862            tier,
5863            None,
5864            None,
5865            None,
5866            None,
5867            Vec::new(),
5868            Vec::new(),
5869            Vec::new(),
5870            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5871            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(tier, declared)),
5872            crate::cgroup::IoMaxReport::default(),
5873        )
5874    }
5875
5876    fn demotion_info() -> crate::detached::DetachedDispatchInfo {
5877        crate::detached::DetachedDispatchInfo {
5878            work_id: "wrk_0a1b2c3d4e5f6a7b".to_string(),
5879            binary: "/usr/bin/bash".to_string(),
5880            command: "make -j8".to_string(),
5881            grace_ms: Some(1_000),
5882        }
5883    }
5884
5885    /// A demotion whose marker cannot be written stands anyway: the write failure is reported and
5886    /// swallowed, and the session carries on with the command running in the background.
5887    #[tokio::test]
5888    async fn a_demotion_marker_that_cannot_be_written_does_not_fail_the_demotion() {
5889        let dir = tempfile::tempdir().unwrap();
5890        // `/dev/full` opens and fails every write with `ENOSPC`, which is the one way to get a
5891        // trace handle that is valid and unwritable without root. Swapped in under the writer,
5892        // because a link at `trace.jsonl` is refused when the writer opens it.
5893        let mut writer = make_writer(dir.path()).await;
5894        let full = std::fs::OpenOptions::new()
5895            .write(true)
5896            .open("/dev/full")
5897            .unwrap();
5898        writer.writer = BufWriter::new(File::from_std(full));
5899        let info = demotion_info();
5900
5901        assert!(
5902            writer
5903                .write_shell_detached(1, &info.work_id, &info.binary, &info.command, info.grace_ms)
5904                .await
5905                .is_err(),
5906            "the sink under this writer must be unwritable for the claim below to mean anything"
5907        );
5908        crate::agent::record_demotion(&mut writer, 1, &info).await;
5909    }
5910
5911    /// The ordinary path writes one `shell_detached` and nothing else.
5912    #[tokio::test]
5913    async fn a_recorded_demotion_writes_only_its_own_marker() {
5914        let dir = tempfile::tempdir().unwrap();
5915        let mut writer = make_writer(dir.path()).await;
5916        crate::agent::record_demotion(&mut writer, 3, &demotion_info()).await;
5917
5918        let events = read_events(dir.path());
5919        assert_eq!(events.len(), 1);
5920        assert_eq!(events[0]["event_type"], "shell_detached");
5921        assert_eq!(events[0]["work_id"], "wrk_0a1b2c3d4e5f6a7b");
5922        assert_eq!(events[0]["turn"], 3);
5923        assert_eq!(events[0]["grace_ms"], 1_000);
5924    }
5925
5926    /// A demotion with no grace in force carries no `grace_ms` key at all.
5927    #[tokio::test]
5928    async fn a_demotion_with_no_grace_writes_no_grace_ms() {
5929        let dir = tempfile::tempdir().unwrap();
5930        let mut writer = make_writer(dir.path()).await;
5931        let info = crate::detached::DetachedDispatchInfo {
5932            grace_ms: None,
5933            ..demotion_info()
5934        };
5935        crate::agent::record_demotion(&mut writer, 3, &info).await;
5936
5937        let events = read_events(dir.path());
5938        assert_eq!(events.len(), 1);
5939        assert_eq!(events[0]["event_type"], "shell_detached");
5940        assert!(events[0].get("grace_ms").is_none(), "{}", events[0]);
5941    }
5942
5943    #[tokio::test]
5944    async fn a_safety_check_event_carries_the_checks_verbatim_and_its_outcome() {
5945        let dir = tempfile::tempdir().unwrap();
5946        let mut writer = make_writer(dir.path()).await;
5947        let checks = serde_json::json!([{"id": "sc_1", "code": "c", "message": "m", "x": 1}]);
5948        writer
5949            .write_safety_check(2, "call_1_0", "computer", &checks, "declined")
5950            .await
5951            .unwrap();
5952
5953        let events = read_events(dir.path());
5954        assert_eq!(events.len(), 1);
5955        assert_eq!(events[0]["event_type"], "safety_check");
5956        assert_eq!(events[0]["turn"], 2);
5957        assert_eq!(events[0]["tool_call_id"], "call_1_0");
5958        assert_eq!(events[0]["tool"], "computer");
5959        assert_eq!(events[0]["checks"], checks);
5960        assert_eq!(events[0]["outcome"], "declined");
5961        assert!(events[0]["event_id"].is_string() && events[0]["session_id"].is_string());
5962    }
5963
5964    #[tokio::test]
5965    async fn an_unrecorded_demotion_names_the_work_id_and_the_write_error() {
5966        let dir = tempfile::tempdir().unwrap();
5967        let mut writer = make_writer(dir.path()).await;
5968        writer
5969            .write_shell_detach_unrecorded(2, "wrk_0a1b", "/usr/bin/bash", "No space left")
5970            .await
5971            .unwrap();
5972
5973        let events = read_events(dir.path());
5974        assert_eq!(events.len(), 1);
5975        assert_eq!(events[0]["event_type"], "shell_detach_unrecorded");
5976        assert_eq!(events[0]["turn"], 2);
5977        assert_eq!(events[0]["work_id"], "wrk_0a1b");
5978        assert_eq!(events[0]["binary"], "/usr/bin/bash");
5979        assert_eq!(events[0]["reason"], "No space left");
5980    }
5981
5982    /// A command still running when the session ended has no exit code, no output file and no
5983    /// byte count, and its line carries none of the three — omitted rather than `null`, so the
5984    /// shape is identical to a line written before those fields existed.
5985    #[tokio::test]
5986    async fn an_abandoned_command_still_running_omits_the_fields_it_cannot_know() {
5987        let dir = tempfile::tempdir().unwrap();
5988        let mut writer = make_writer(dir.path()).await;
5989        writer
5990            .write_shell_abandoned(
5991                "wrk_0a1b2c3d4e5f6a7b",
5992                "/usr/bin/bash",
5993                "sleep 45; echo done",
5994                45_012,
5995                None,
5996            )
5997            .await
5998            .unwrap();
5999
6000        let events = read_events(dir.path());
6001        assert_eq!(events.len(), 1);
6002        let event = &events[0];
6003        assert_eq!(event["event_type"], "shell_abandoned");
6004        assert_eq!(event["work_id"], "wrk_0a1b2c3d4e5f6a7b");
6005        assert_eq!(event["binary"], "/usr/bin/bash");
6006        assert_eq!(event["command"], "sleep 45; echo done");
6007        assert_eq!(event["running_ms"], 45_012);
6008        for absent in ["exit_code", "output_path", "output_bytes"] {
6009            assert!(
6010                event.get(absent).is_none(),
6011                "{absent} must be omitted, not written as null: {event}"
6012            );
6013        }
6014    }
6015
6016    /// A command that finished during teardown carries the exit code, the output path and the
6017    /// byte count that do exist for it, so a trace reader can tell it from one still running
6018    /// without reading anything else.
6019    #[tokio::test]
6020    async fn an_abandoned_command_that_finished_carries_its_result() {
6021        let dir = tempfile::tempdir().unwrap();
6022        let mut writer = make_writer(dir.path()).await;
6023        writer
6024            .write_shell_abandoned(
6025                "wrk_0a1b2c3d4e5f6a7b",
6026                "/usr/bin/bash",
6027                "sleep 2; echo done",
6028                2_014,
6029                Some((
6030                    0,
6031                    "/home/test/.murmur/records/work/ses_1/logs/wrk_0a1b2c3d4e5f6a7b.log",
6032                    91,
6033                )),
6034            )
6035            .await
6036            .unwrap();
6037
6038        let event = &read_events(dir.path())[0];
6039        assert_eq!(event["event_type"], "shell_abandoned");
6040        assert_eq!(event["running_ms"], 2_014);
6041        assert_eq!(event["exit_code"], 0);
6042        assert_eq!(
6043            event["output_path"],
6044            "/home/test/.murmur/records/work/ses_1/logs/wrk_0a1b2c3d4e5f6a7b.log"
6045        );
6046        assert_eq!(event["output_bytes"], 91);
6047    }
6048
6049    async fn make_writer(dir: &std::path::Path) -> TraceWriter {
6050        make_writer_with_opts(dir, TraceCapture::Meta).await
6051    }
6052
6053    async fn make_writer_with_opts(dir: &std::path::Path, capture: TraceCapture) -> TraceWriter {
6054        make_writer_with_prompt(dir, capture, None, false).await
6055    }
6056
6057    async fn make_writer_with_prompt(
6058        dir: &std::path::Path,
6059        capture: TraceCapture,
6060        system_prompt: Option<&str>,
6061        system_prompt_overridden: bool,
6062    ) -> TraceWriter {
6063        TraceWriter::open(
6064            &crate::workdir_fs::WorkdirRoot::open(dir).unwrap(),
6065            "test-session-id".to_string(),
6066            "test-capsule".to_string(),
6067            "1.0.0".to_string(),
6068            "claude-test".to_string(),
6069            vec!["shell".to_string()],
6070            report_for(
6071                ContainmentClass::Advisory,
6072                EnforcementTier::EnvironmentOnly,
6073                false,
6074            ),
6075            capture,
6076            system_prompt.map(str::to_string),
6077            system_prompt_overridden,
6078            None,
6079            None,
6080            None,
6081            None,
6082        )
6083        .await
6084        .unwrap()
6085    }
6086
6087    fn read_events(dir: &std::path::Path) -> Vec<Value> {
6088        let content = std::fs::read_to_string(dir.join("trace.jsonl")).unwrap();
6089        content
6090            .lines()
6091            .filter(|l| !l.is_empty())
6092            .map(|l| serde_json::from_str(l).unwrap())
6093            .collect()
6094    }
6095
6096    #[tokio::test]
6097    async fn session_start_fields() {
6098        let dir = tempfile::tempdir().unwrap();
6099        let mut w = make_writer(dir.path()).await;
6100        w.write_session_start(Some(10), None, vec!["bash".to_string()])
6101            .await
6102            .unwrap();
6103        w.flush().await.unwrap();
6104
6105        let events = read_events(dir.path());
6106        assert_eq!(events.len(), 1);
6107        let e = &events[0];
6108        assert_eq!(e["event_type"], "session_start");
6109        assert_eq!(e["session_id"], "test-session-id");
6110        assert_eq!(e["capsule_name"], "test-capsule");
6111        assert_eq!(e["capsule_version"], "1.0.0");
6112        assert_eq!(e["model"], "claude-test");
6113        assert_eq!(e["max_turns"], 10);
6114        assert_eq!(e["capabilities"], serde_json::json!(["shell"]));
6115        assert_eq!(e["tools_declared"], serde_json::json!(["bash"]));
6116        assert_eq!(e["containment_declared"], "advisory");
6117        assert_eq!(e["containment_achieved"], "advisory");
6118        assert_eq!(e["workdir_exec"], false);
6119        assert!(e["timestamp"].as_u64().unwrap() > 0);
6120        assert_eq!(e["model_limits"], serde_json::Value::Null);
6121    }
6122
6123    /// No turn cap is written as `null`, beside the primary's limits and their sources.
6124    #[tokio::test]
6125    async fn session_start_writes_no_turn_cap_and_the_model_limits() {
6126        let dir = tempfile::tempdir().unwrap();
6127        let mut w = make_writer(dir.path()).await;
6128        let limits = crate::model_limits::ResolvedModelLimits {
6129            max_output_tokens: 65536,
6130            max_output_tokens_source: crate::model_limits::LimitSource::Driver,
6131            context_window: None,
6132            context_window_source: crate::model_limits::LimitSource::None,
6133            max_input_tokens: None,
6134            max_input_tokens_source: crate::model_limits::LimitSource::None,
6135            input_budget: None,
6136            input_budget_basis: crate::model_limits::BudgetBasis::None,
6137        };
6138        w.write_session_start(None, Some(limits), Vec::new())
6139            .await
6140            .unwrap();
6141        w.flush().await.unwrap();
6142
6143        let e = &read_events(dir.path())[0];
6144        assert!(e.get("max_turns").is_some_and(serde_json::Value::is_null));
6145        assert_eq!(
6146            e["model_limits"],
6147            serde_json::json!({
6148                "max_output_tokens": 65536,
6149                "max_output_tokens_source": "driver",
6150                "context_window": null,
6151                "context_window_source": "none",
6152                "max_input_tokens": null,
6153                "max_input_tokens_source": "none",
6154                "input_budget": null,
6155                "input_budget_basis": "none",
6156            })
6157        );
6158    }
6159
6160    /// A member's formation is on the trace's first line, which is all a reader grouping sessions
6161    /// reads; a session in no formation writes no key at all.
6162    #[tokio::test]
6163    async fn session_start_is_the_first_line_and_carries_a_members_formation() {
6164        let formation = FormationId::mint();
6165        for member in [Some(&formation), None] {
6166            let dir = tempfile::tempdir().unwrap();
6167            let mut w = make_writer(dir.path()).await;
6168            w.set_formation_id(member);
6169            w.write_session_start(Some(10), None, Vec::new())
6170                .await
6171                .unwrap();
6172            w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
6173                .await
6174                .unwrap();
6175            w.flush().await.unwrap();
6176
6177            let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
6178            let first_line = content.lines().next().unwrap();
6179            let first: Value = serde_json::from_str(first_line).unwrap();
6180            assert_eq!(first["event_type"], "session_start");
6181            match member {
6182                Some(id) => {
6183                    assert_eq!(first["formation_id"], id.as_str());
6184                    assert!(
6185                        first_line.contains(&format!("\"formation_id\":\"{id}\"")),
6186                        "{first_line}"
6187                    );
6188                }
6189                None => assert!(!first_line.contains("formation_id"), "{first_line}"),
6190            }
6191            // Only the session frame carries it.
6192            assert_eq!(
6193                content.matches("formation_id").count(),
6194                usize::from(member.is_some())
6195            );
6196        }
6197    }
6198
6199    /// A member's `session_start` names it and its callees, `[]` for none, and only a member's
6200    /// does; `a2a_task_received.caller_member` is present only for a task a formation token let
6201    /// in.
6202    #[tokio::test]
6203    async fn a_members_trace_names_it_its_callees_and_its_callers() {
6204        let authority = crate::formation_credentials::FormationAuthority::for_test();
6205        let cases = [
6206            (Some(("coder", vec!["reviewer"])), Some("planner")),
6207            (Some(("reviewer", vec![])), None),
6208            (None, None),
6209        ];
6210        for (member, caller) in cases {
6211            let member = member.map(|(name, callees)| {
6212                crate::formation_credentials::FormationMember::from_bundle(
6213                    authority.member_bundle(name, &callees),
6214                )
6215            });
6216            let dir = tempfile::tempdir().unwrap();
6217            let mut w = make_writer(dir.path()).await;
6218            w.set_formation_member(member.as_ref());
6219            w.write_session_start(Some(10), None, Vec::new())
6220                .await
6221                .unwrap();
6222            let door_caller = match caller {
6223                Some(caller) => crate::identity::TaskCaller::Credential(format!("member:{caller}")),
6224                None => crate::identity::TaskCaller::Operator,
6225            };
6226            w.write_a2a_task_received("tsk_1", "ctx_1", "msg_1", None, &door_caller, caller, "hi")
6227                .await
6228                .unwrap();
6229            w.flush().await.unwrap();
6230            let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
6231            let lines: Vec<Value> = content
6232                .lines()
6233                .map(|line| serde_json::from_str(line).unwrap())
6234                .collect();
6235            let start = &lines[0];
6236            let received = lines
6237                .iter()
6238                .find(|line| line["event_type"] == "a2a_task_received")
6239                .unwrap();
6240            match &member {
6241                Some(member) => {
6242                    assert_eq!(start["formation_member"], member.name());
6243                    assert_eq!(
6244                        start["formation_callees"],
6245                        serde_json::json!(member.callees().collect::<Vec<_>>())
6246                    );
6247                }
6248                None => {
6249                    assert!(start.get("formation_member").is_none(), "{start}");
6250                    assert!(start.get("formation_callees").is_none(), "{start}");
6251                }
6252            }
6253            match caller {
6254                Some(caller) => {
6255                    assert_eq!(received["caller"], "member");
6256                    assert_eq!(received["caller_member"], caller);
6257                }
6258                None => {
6259                    assert_eq!(received["caller"], "operator");
6260                    assert!(received.get("caller_member").is_none(), "{received}");
6261                }
6262            }
6263            assert!(received.get("caller_credential").is_none(), "{received}");
6264            assert!(!content.contains("mft1."), "{content}");
6265        }
6266    }
6267
6268    /// `a2a_task_received.caller` names each way a door lets a task in, with `caller_member` only
6269    /// for a member and `caller_credential` only for a declared credential; a formation token's
6270    /// task is the member's whatever credential the token carries.
6271    #[tokio::test]
6272    async fn a_received_task_names_who_the_door_let_it_in_as() {
6273        use crate::identity::TaskCaller;
6274        let cases = [
6275            (TaskCaller::Operator, None, "operator", None),
6276            (TaskCaller::Public, None, "public", None),
6277            (
6278                TaskCaller::Credential("alice".to_string()),
6279                None,
6280                "credential",
6281                Some("alice"),
6282            ),
6283            (
6284                TaskCaller::Credential("member:planner".to_string()),
6285                Some("planner"),
6286                "member",
6287                None,
6288            ),
6289        ];
6290        for (door_caller, member, caller, credential) in cases {
6291            let dir = tempfile::tempdir().unwrap();
6292            let mut w = make_writer(dir.path()).await;
6293            w.write_session_start(Some(10), None, Vec::new())
6294                .await
6295                .unwrap();
6296            w.write_a2a_task_received("tsk_1", "ctx_1", "msg_1", None, &door_caller, member, "hi")
6297                .await
6298                .unwrap();
6299            w.flush().await.unwrap();
6300            let received = read_events(dir.path())
6301                .into_iter()
6302                .find(|line| line["event_type"] == "a2a_task_received")
6303                .unwrap();
6304            assert_eq!(received["caller"], caller, "{received}");
6305            match member {
6306                Some(member) => assert_eq!(received["caller_member"], member, "{received}"),
6307                None => assert!(received.get("caller_member").is_none(), "{received}"),
6308            }
6309            match credential {
6310                Some(name) => assert_eq!(received["caller_credential"], name, "{received}"),
6311                None => assert!(received.get("caller_credential").is_none(), "{received}"),
6312            }
6313        }
6314    }
6315
6316    /// The received message's hash is written under `meta` and `content`, and only `content`
6317    /// stores the text behind it; `none` writes neither. The text never reaches the JSONL.
6318    #[tokio::test]
6319    async fn a_received_message_is_hashed_and_stored_by_capture_mode() {
6320        let text = "Name three colours, please.";
6321        let sha = murmur_artifact::sha256_hex(text.as_bytes());
6322        for capture in [
6323            TraceCapture::None,
6324            TraceCapture::Meta,
6325            TraceCapture::Content,
6326        ] {
6327            let dir = tempfile::tempdir().unwrap();
6328            let mut w = make_writer_with_opts(dir.path(), capture).await;
6329            w.write_a2a_task_received(
6330                "tsk_1",
6331                "ctx_1",
6332                "msg_1",
6333                None,
6334                &crate::identity::TaskCaller::Operator,
6335                None,
6336                text,
6337            )
6338            .await
6339            .unwrap();
6340            w.flush().await.unwrap();
6341            let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
6342            assert!(!content.contains(text), "{content}");
6343            let received = read_events(dir.path())
6344                .into_iter()
6345                .find(|line| line["event_type"] == "a2a_task_received")
6346                .unwrap();
6347            let blob = std::fs::read(blob_path(dir.path(), &sha)).ok();
6348            match capture {
6349                TraceCapture::None => {
6350                    assert!(received.get("message_sha256").is_none(), "{received}");
6351                    assert_eq!(blob, None);
6352                }
6353                TraceCapture::Meta => {
6354                    assert_eq!(received["message_sha256"], sha.as_str(), "{received}");
6355                    assert_eq!(blob, None);
6356                }
6357                TraceCapture::Content => {
6358                    assert_eq!(received["message_sha256"], sha.as_str(), "{received}");
6359                    assert_eq!(blob.as_deref(), Some(text.as_bytes()));
6360                }
6361            }
6362        }
6363    }
6364
6365    /// The three `system_prompt_source` values, each paired with the hash the same call derives.
6366    /// A manifest prompt and a CLI override carrying the *same* text differ only in the source
6367    /// field — which is the whole reason the override flag is threaded through separately rather
6368    /// than inferred from the resolved prompt.
6369    #[tokio::test]
6370    async fn session_start_records_system_prompt_source_and_hash() {
6371        let expected_sha = murmur_artifact::sha256_hex(b"Be terse.");
6372
6373        for (prompt, overridden, source, sha) in [
6374            (Some("Be terse."), false, "manifest", Some(&expected_sha)),
6375            (Some("Be terse."), true, "cli", Some(&expected_sha)),
6376            (None, false, "none", None),
6377            // `--system-prompt ""` cleared the manifest's prompt: no prompt is in effect, but the
6378            // operator made that call, so the source is the CLI and not `"none"`.
6379            (None, true, "cli", None),
6380        ] {
6381            let dir = tempfile::tempdir().unwrap();
6382            let mut w =
6383                make_writer_with_prompt(dir.path(), TraceCapture::Meta, prompt, overridden).await;
6384            w.write_session_start(Some(1), None, Vec::new())
6385                .await
6386                .unwrap();
6387            w.flush().await.unwrap();
6388
6389            let e = &read_events(dir.path())[0];
6390            assert_eq!(e["system_prompt_source"], source, "prompt={prompt:?}");
6391            match sha {
6392                Some(sha) => assert_eq!(e["system_prompt_sha256"], sha.as_str()),
6393                None => assert!(
6394                    e["system_prompt_sha256"].is_null(),
6395                    "no prompt in effect must hash to null, got {}",
6396                    e["system_prompt_sha256"]
6397                ),
6398            }
6399        }
6400    }
6401
6402    /// `session_start` never carries the prompt text. The hash is written in every capture
6403    /// mode; under `content` the bytes behind it are a blob named by that same hash, which is
6404    /// how a reader gets from the record to the prompt.
6405    #[tokio::test]
6406    async fn session_start_records_the_prompt_by_hash_and_stores_it_only_under_content() {
6407        let sha = murmur_artifact::sha256_hex(b"Be terse.");
6408
6409        let dir = tempfile::tempdir().unwrap();
6410        let mut w =
6411            make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), false).await;
6412        w.write_session_start(Some(1), None, Vec::new())
6413            .await
6414            .unwrap();
6415        w.flush().await.unwrap();
6416
6417        let e = &read_events(dir.path())[0];
6418        assert!(
6419            e.get("system_prompt").is_none(),
6420            "the verbatim prompt is not a session_start field, got {e}"
6421        );
6422        assert_eq!(e["system_prompt_sha256"], sha.as_str());
6423        assert!(
6424            !dir.path().join(BLOB_DIR_NAME).exists(),
6425            "meta stores no bodies"
6426        );
6427
6428        let opted_in = tempfile::tempdir().unwrap();
6429        let mut w = make_writer_with_prompt(
6430            opted_in.path(),
6431            TraceCapture::Content,
6432            Some("Be terse."),
6433            false,
6434        )
6435        .await;
6436        w.write_session_start(Some(1), None, Vec::new())
6437            .await
6438            .unwrap();
6439        w.flush().await.unwrap();
6440
6441        let event = &read_events(opted_in.path())[0];
6442        assert!(event.get("system_prompt").is_none());
6443        assert_eq!(event["system_prompt_sha256"], sha.as_str());
6444        let blob = opted_in.path().join(BLOB_DIR_NAME).join(&sha);
6445        assert_eq!(std::fs::read(&blob).unwrap(), b"Be terse.");
6446    }
6447
6448    /// Opting in cannot conjure a prompt that was never in effect — and a session with no
6449    /// prompt and nothing else to store leaves no blob directory at all.
6450    #[tokio::test]
6451    async fn content_capture_stores_no_prompt_blob_when_there_is_no_prompt() {
6452        let dir = tempfile::tempdir().unwrap();
6453        let mut w = make_writer_with_prompt(dir.path(), TraceCapture::Content, None, false).await;
6454        w.write_session_start(Some(1), None, Vec::new())
6455            .await
6456            .unwrap();
6457        w.flush().await.unwrap();
6458
6459        let e = &read_events(dir.path())[0];
6460        assert!(e.get("system_prompt").is_none());
6461        assert!(e["system_prompt_sha256"].is_null());
6462        assert!(!dir.path().join(BLOB_DIR_NAME).exists());
6463    }
6464
6465    /// The prompt is a session constant, like `model` — every task's `session_start` repeats it.
6466    #[tokio::test]
6467    async fn every_task_session_start_repeats_the_same_system_prompt_record() {
6468        let dir = tempfile::tempdir().unwrap();
6469        let mut w =
6470            make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), true).await;
6471        w.write_session_start(Some(1), None, Vec::new())
6472            .await
6473            .unwrap();
6474        w.write_session_start(Some(1), None, Vec::new())
6475            .await
6476            .unwrap();
6477        w.flush().await.unwrap();
6478
6479        let events = read_events(dir.path());
6480        assert_eq!(events.len(), 2);
6481        assert_eq!(
6482            events[0]["system_prompt_source"],
6483            events[1]["system_prompt_source"]
6484        );
6485        assert_eq!(
6486            events[0]["system_prompt_sha256"],
6487            events[1]["system_prompt_sha256"]
6488        );
6489        assert_eq!(events[1]["system_prompt_source"], "cli");
6490    }
6491
6492    /// The pairing an operator reads a trace for: `workdir_exec: true` next to the `advisory` it
6493    /// forces, on a session whose *declared* floor was higher. Without the flag on the record, a
6494    /// trace showing `achieved: advisory` is indistinguishable from one written on a host with no
6495    /// Landlock at all.
6496    #[tokio::test]
6497    async fn session_start_records_workdir_exec_next_to_the_class_it_forced() {
6498        let dir = tempfile::tempdir().unwrap();
6499        let mut w = TraceWriter::open(
6500            &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6501            "test-session-id".to_string(),
6502            "test-capsule".to_string(),
6503            "1.0.0".to_string(),
6504            "claude-test".to_string(),
6505            Vec::new(),
6506            // A host that could back `scoped`, capped to `advisory` by the declaration alone.
6507            report_for(
6508                ContainmentClass::Advisory,
6509                EnforcementTier::KernelFull,
6510                true,
6511            ),
6512            TraceCapture::Meta,
6513            None,
6514            false,
6515            None,
6516            None,
6517            None,
6518            None,
6519        )
6520        .await
6521        .unwrap();
6522        w.write_session_start(Some(1), None, Vec::new())
6523            .await
6524            .unwrap();
6525        w.flush().await.unwrap();
6526
6527        let events = read_events(dir.path());
6528        assert_eq!(events[0]["workdir_exec"], true);
6529        assert_eq!(events[0]["containment_achieved"], "advisory");
6530    }
6531
6532    /// A declared floor and an achieved class are recorded independently — the trace shows
6533    /// what was asked for next to what the host actually gave, not one derived from the other.
6534    #[tokio::test]
6535    async fn session_start_records_declared_and_achieved_containment_separately() {
6536        let dir = tempfile::tempdir().unwrap();
6537        let mut w = TraceWriter::open(
6538            &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6539            "test-session-id".to_string(),
6540            "test-capsule".to_string(),
6541            "1.0.0".to_string(),
6542            "claude-test".to_string(),
6543            Vec::new(),
6544            report_for(ContainmentClass::Sealed, EnforcementTier::KernelFull, false),
6545            TraceCapture::Meta,
6546            None,
6547            false,
6548            None,
6549            None,
6550            None,
6551            None,
6552        )
6553        .await
6554        .unwrap();
6555        w.write_session_start(Some(1), None, Vec::new())
6556            .await
6557            .unwrap();
6558        w.flush().await.unwrap();
6559
6560        let events = read_events(dir.path());
6561        assert_eq!(events[0]["containment_declared"], "sealed");
6562        assert_eq!(events[0]["containment_achieved"], "scoped");
6563    }
6564
6565    /// The audit property the containment feature exists to provide: two sessions that reached
6566    /// the *same* achieved class through *different* host permissions must not produce the same
6567    /// record. A `sealed` result obtained through the shipped AppArmor profile and one obtained on
6568    /// a host whose unprivileged-userns hardening is switched off for every binary differ in
6569    /// nothing else the event carries, so `userns_grant` is what keeps the two apart.
6570    ///
6571    /// The tier is a literal on both sides, so the only thing that differs is the grant.
6572    #[tokio::test]
6573    async fn session_start_distinguishes_two_hosts_with_the_same_achieved_class() {
6574        async fn event_for(grant: UsernsGrant) -> Value {
6575            let report = crate::containment::report_for(
6576                &CapabilityPolicy::default(),
6577                ContainmentClass::Sealed,
6578                EnforcementTier::KernelSealed,
6579                None,
6580                Some(grant),
6581                None,
6582                None,
6583                Vec::new(),
6584                Vec::new(),
6585                Vec::new(),
6586                &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6587                crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6588                    EnforcementTier::KernelSealed,
6589                    ContainmentClass::Sealed,
6590                )),
6591                crate::cgroup::IoMaxReport::default(),
6592            );
6593            let dir = tempfile::tempdir().unwrap();
6594            let mut w = TraceWriter::open(
6595                &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6596                "test-session-id".to_string(),
6597                "test-capsule".to_string(),
6598                "1.0.0".to_string(),
6599                "claude-test".to_string(),
6600                Vec::new(),
6601                report,
6602                TraceCapture::Meta,
6603                None,
6604                false,
6605                None,
6606                None,
6607                None,
6608                None,
6609            )
6610            .await
6611            .unwrap();
6612            w.write_session_start(Some(1), None, Vec::new())
6613                .await
6614                .unwrap();
6615            w.flush().await.unwrap();
6616            let mut event = read_events(dir.path()).remove(0);
6617            // The one field that legitimately differs between two runs, removed so the assertion
6618            // below is about the grant and nothing else.
6619            event.as_object_mut().unwrap().remove("timestamp");
6620            event
6621        }
6622
6623        let through_profile = event_for(UsernsGrant::ProfileConfining).await;
6624        let host_wide = event_for(UsernsGrant::RestrictionDisabledHostWide).await;
6625
6626        assert_eq!(through_profile["containment_achieved"], "sealed");
6627        assert_eq!(host_wide["containment_achieved"], "sealed");
6628        assert_eq!(through_profile["userns_grant"], "profile_confining");
6629        assert_eq!(host_wide["userns_grant"], "restriction_disabled_host_wide");
6630        assert_ne!(
6631            through_profile, host_wide,
6632            "two hosts granting the user namespace by different mechanisms must not write the \
6633             same session_start record"
6634        );
6635
6636        // Written for every Linux host, never skipped — its absence identifies a runtime that
6637        // predates the key rather than a host that was not asked.
6638        let unprobed = crate::containment::report_for(
6639            &CapabilityPolicy::default(),
6640            ContainmentClass::Advisory,
6641            EnforcementTier::EnvironmentOnly,
6642            None,
6643            None,
6644            None,
6645            None,
6646            Vec::new(),
6647            Vec::new(),
6648            Vec::new(),
6649            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6650            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6651                EnforcementTier::EnvironmentOnly,
6652                ContainmentClass::Advisory,
6653            )),
6654            crate::cgroup::IoMaxReport::default(),
6655        );
6656        assert!(
6657            serde_json::to_value(&unprobed).unwrap()["userns_grant"].is_null(),
6658            "off Linux the key is present and null, not absent"
6659        );
6660    }
6661
6662    /// Central property: `session_start.effective_grants` is the *whole* `ScopeReport`,
6663    /// serialized byte-for-byte as `mur run --explain-scope --json` prints it — not a re-derived
6664    /// summary of it. Asserted by comparing against `serde_json::to_value` of the very report
6665    /// handed to the writer, so any field added to `ScopeReport` later is covered without this
6666    /// test being touched.
6667    ///
6668    /// The tier is a literal, never a probe: this asserts the recording, not the host.
6669    #[tokio::test]
6670    async fn session_start_records_the_whole_scope_report_as_effective_grants() {
6671        let policy = CapabilityPolicy {
6672            network_allow: vec!["https://api.example.com".to_string()],
6673            unix_sockets_allowed: false,
6674            filesystem_scope: Some("workdir".to_string()),
6675            shell_allow: vec!["python3".to_string()],
6676            spawn_allow: vec!["helper".to_string()],
6677            env_allow: vec!["TZ".to_string()],
6678            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
6679                binary: "python3".to_string(),
6680                dirs: vec![InterpreterRuntimeDir {
6681                    path: "/usr/lib/python3.11".to_string(),
6682                    list_dir: true,
6683                }],
6684            }],
6685            ..CapabilityPolicy::default()
6686        };
6687        let expected = crate::containment::report_for(
6688            &policy,
6689            ContainmentClass::Scoped,
6690            EnforcementTier::KernelFull,
6691            None,
6692            None,
6693            // A kernel above the cap, so `landlock_abi` is written as an integer, not `null`.
6694            Some(8),
6695            None,
6696            // Populated rather than empty so the whole-report assertion below really covers this
6697            // field: an empty vec would pass whether or not it was written at all.
6698            vec![crate::containment::StateStoreReport {
6699                artifact: "notes-tool".to_string(),
6700                store: "shey".to_string(),
6701                host_path: "/home/dev/.murmur/state/shey".to_string(),
6702            }],
6703            // Populated for the same reason as `state_stores` above.
6704            vec!["config-echo".to_string()],
6705            vec![crate::containment::PreopenReport {
6706                artifact: "notes-tool".to_string(),
6707                role: "tool".to_string(),
6708                scope: None,
6709                surface: crate::containment::PreopenSurface::WholeWorkdir,
6710                runtime_dir_reach: Some(crate::containment::RuntimeDirReach::MurmurRuntime),
6711                // Populated for the same reason as `state_stores` above.
6712                extra_roots: vec![crate::containment::ExtraRootReport {
6713                    guest_path: "murmur-host/home/dev/.cargo".to_string(),
6714                    path: "/home/dev/.cargo".to_string(),
6715                    access: crate::host_paths::HostPathAccess::Read,
6716                    source: crate::guest_roots::GuestRootSource::HostPaths,
6717                }],
6718            }],
6719            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6720            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6721                EnforcementTier::KernelFull,
6722                ContainmentClass::Scoped,
6723            )),
6724            crate::cgroup::IoMaxReport::default(),
6725        );
6726
6727        let dir = tempfile::tempdir().unwrap();
6728        let mut w = TraceWriter::open(
6729            &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6730            "test-session-id".to_string(),
6731            "test-capsule".to_string(),
6732            "1.0.0".to_string(),
6733            "claude-test".to_string(),
6734            vec!["network".to_string(), "shell".to_string()],
6735            expected.clone(),
6736            TraceCapture::Meta,
6737            None,
6738            false,
6739            None,
6740            None,
6741            None,
6742            None,
6743        )
6744        .await
6745        .unwrap();
6746        w.write_session_start(Some(1), None, Vec::new())
6747            .await
6748            .unwrap();
6749        w.flush().await.unwrap();
6750
6751        let events = read_events(dir.path());
6752        assert_eq!(
6753            events[0]["effective_grants"],
6754            serde_json::to_value(&expected).unwrap()
6755        );
6756
6757        // The category-name summary that predates this field is unchanged, and the three
6758        // top-level containment fields agree with the report they are now read from.
6759        assert_eq!(
6760            events[0]["capabilities"],
6761            serde_json::json!(["network", "shell"])
6762        );
6763        assert_eq!(
6764            events[0]["effective_grants"]["state_stores"],
6765            serde_json::json!([{
6766                "artifact": "notes-tool",
6767                "store": "shey",
6768                "host_path": "/home/dev/.murmur/state/shey",
6769            }])
6770        );
6771        // Names only: what an artifact is configured *with* is operator plaintext and reaches
6772        // no trace record.
6773        assert_eq!(
6774            events[0]["effective_grants"]["configured_artifacts"],
6775            serde_json::json!(["config-echo"])
6776        );
6777        // The per-artifact preopen set reaches the trace with no trace-side plumbing: the report
6778        // is cloned whole, so `filesystem_scope` above (which narrows no guest) and the surface a
6779        // guest is actually opened into are both on the record.
6780        assert_eq!(
6781            events[0]["effective_grants"]["preopens"],
6782            serde_json::json!([{
6783                "artifact": "notes-tool",
6784                "role": "tool",
6785                "scope": null,
6786                "surface": "whole-workdir",
6787                "runtime_dir_reach": "murmur-runtime",
6788                "extra_roots": [{
6789                    "guest_path": "murmur-host/home/dev/.cargo",
6790                    "path": "/home/dev/.cargo",
6791                    "access": "read",
6792                    "source": "host_paths",
6793                }],
6794            }])
6795        );
6796        assert_eq!(events[0]["containment_declared"], "scoped");
6797        assert_eq!(events[0]["containment_achieved"], "scoped");
6798        assert_eq!(events[0]["workdir_exec"], false);
6799        assert_eq!(
6800            events[0]["effective_grants"]["network_allow"],
6801            serde_json::json!(["https://api.example.com"])
6802        );
6803        assert_eq!(
6804            events[0]["effective_grants"]["interpreter_runtime_grants"],
6805            serde_json::json!(["python3: /usr/lib/python3.11 (list_dir)"])
6806        );
6807    }
6808
6809    #[tokio::test]
6810    async fn inference_fields_and_snake_case() {
6811        let dir = tempfile::tempdir().unwrap();
6812        let mut w = make_writer(dir.path()).await;
6813        w.write_inference(
6814            0,
6815            Some(100),
6816            Some(50),
6817            "tool_call".to_string(),
6818            Some("tool_call"),
6819            Some("bash".to_string()),
6820            None,
6821            None,
6822            vec!["msg_0000000000000000000000000000000a".to_string()],
6823            None,
6824            None,
6825        )
6826        .await
6827        .unwrap();
6828        w.flush().await.unwrap();
6829
6830        let events = read_events(dir.path());
6831        let e = &events[0];
6832        assert_eq!(e["event_type"], "inference");
6833        assert_eq!(
6834            e["message_ids"],
6835            serde_json::json!(["msg_0000000000000000000000000000000a"]),
6836            "an agent-loop turn names the messages its request embedded"
6837        );
6838        assert_eq!(e["turn"], 0);
6839        assert_eq!(e["input_tokens"], 100);
6840        assert_eq!(e["output_tokens"], 50);
6841        assert_eq!(e["decision"], "tool_call");
6842        assert_eq!(e["tool_name"], "bash");
6843        // Confirm snake_case (not camelCase)
6844        assert!(e.get("inputTokens").is_none(), "must use snake_case");
6845        assert!(e.get("toolName").is_none(), "must use snake_case");
6846        // No driver reported usage, so no key claims one was reported.
6847        for key in [
6848            "input_tokens_actual",
6849            "output_tokens_actual",
6850            "cached_tokens",
6851            "cache_write_tokens",
6852        ] {
6853            assert!(e.get(key).is_none(), "{key} must be omitted, not null or 0");
6854        }
6855    }
6856
6857    /// A partially-populated `usage` writes only the members the driver reported: a member it
6858    /// left out stays off the line entirely rather than being recorded as a zero.
6859    #[tokio::test]
6860    async fn inference_writes_only_the_reported_usage_members() {
6861        let dir = tempfile::tempdir().unwrap();
6862        let mut w = make_writer(dir.path()).await;
6863        let usage = DriverUsage {
6864            input_tokens: Some(12043),
6865            output_tokens: None,
6866            cached_tokens: Some(0),
6867            cache_write_tokens: None,
6868            thinking_tokens: None,
6869        };
6870        w.write_inference(
6871            0,
6872            Some(100),
6873            Some(50),
6874            "end_turn".to_string(),
6875            Some("end_turn"),
6876            None,
6877            None,
6878            Some(&usage),
6879            Vec::new(),
6880            None,
6881            None,
6882        )
6883        .await
6884        .unwrap();
6885        w.flush().await.unwrap();
6886
6887        let events = read_events(dir.path());
6888        let e = &events[0];
6889        assert_eq!(e["input_tokens"], 100, "the estimate is untouched");
6890        assert_eq!(e["input_tokens_actual"], 12043);
6891        assert_eq!(e["cached_tokens"], 0, "a reported zero is written");
6892        assert!(e.get("output_tokens_actual").is_none());
6893        assert!(e.get("cache_write_tokens").is_none());
6894    }
6895
6896    #[tokio::test]
6897    async fn failed_inference_writes_its_keys_and_counts_only_what_it_spent() {
6898        let dir = tempfile::tempdir().unwrap();
6899        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
6900        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
6901            .await
6902            .unwrap();
6903        let usage = DriverUsage {
6904            input_tokens: Some(7),
6905            ..DriverUsage::default()
6906        };
6907        let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
6908        w.write_failed_inference(FailedInference {
6909            turn: 0,
6910            error_code: INFERENCE_ERROR_CREDENTIAL_REJECTED,
6911            error: &long,
6912            provider_status: Some(401),
6913            usage: Some(&usage),
6914            origin: None,
6915            choice: None,
6916            message_ids: vec!["msg_1".to_string()],
6917            tool_name: None,
6918            spent: None,
6919            settings: None,
6920            retry: RetryNote::default(),
6921            gateway: None,
6922        })
6923        .await
6924        .unwrap();
6925        assert_eq!(w.task_turns(), 0, "an uncounted record moves no counter");
6926        assert_eq!((w.total_turns, w.total_input_tokens), (0, 0));
6927        assert_eq!((w.task_input_tokens, w.task_output_tokens), (0, 0));
6928
6929        w.write_failed_inference(FailedInference {
6930            turn: 1,
6931            error_code: INFERENCE_ERROR_HARNESS_AUTH,
6932            error: "bad login",
6933            provider_status: None,
6934            usage: None,
6935            origin: None,
6936            choice: None,
6937            message_ids: Vec::new(),
6938            tool_name: None,
6939            spent: Some((Some(10), Some(5))),
6940            settings: None,
6941            retry: RetryNote::default(),
6942            gateway: None,
6943        })
6944        .await
6945        .unwrap();
6946        assert_eq!(w.task_turns(), 1);
6947        assert_eq!(w.total_turns, 1);
6948        assert_eq!((w.total_input_tokens, w.total_output_tokens), (10, 5));
6949        assert_eq!((w.task_input_tokens, w.task_output_tokens), (10, 5));
6950        w.flush().await.unwrap();
6951
6952        let events = read_events(dir.path());
6953        let failed: Vec<&Value> = events
6954            .iter()
6955            .filter(|e| e["event_type"] == "inference")
6956            .collect();
6957        let keys = |e: &Value| {
6958            let mut keys: Vec<String> = e.as_object().unwrap().keys().cloned().collect();
6959            keys.sort();
6960            keys
6961        };
6962        assert_eq!(
6963            keys(failed[0]),
6964            [
6965                "decision",
6966                "error",
6967                "error_code",
6968                "event_id",
6969                "event_type",
6970                "input_tokens_actual",
6971                "message_ids",
6972                "parent_id",
6973                "provider_status",
6974                "session_id",
6975                "stop_reason",
6976                "task_id",
6977                "timestamp",
6978                "tool_name",
6979                "turn",
6980            ]
6981        );
6982        assert_eq!(failed[0]["decision"], "error");
6983        assert_eq!(failed[0]["stop_reason"], "error");
6984        assert_eq!(failed[0]["error_code"], "credential_rejected");
6985        assert_eq!(failed[0]["provider_status"], 401);
6986        assert_eq!(failed[0]["input_tokens_actual"], 7);
6987        let error = failed[0]["error"].as_str().unwrap();
6988        assert!(error.len() <= MAX_TASK_FAILURE_REASON_BYTES);
6989        assert!(error.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
6990
6991        assert_eq!(failed[1]["error_code"], "harness_auth");
6992        assert_eq!(failed[1]["error"], "bad login");
6993        assert_eq!(failed[1]["input_tokens"], 10);
6994        assert_eq!(failed[1]["output_tokens"], 5);
6995        assert!(failed[1].get("provider_status").is_none());
6996        assert!(
6997            !dir.path().join("blobs").exists(),
6998            "a failed record stores no blob, even under trace.capture: content"
6999        );
7000        // The agent-loop failed record is the turn node, parented to the task.
7001        let task_start = events
7002            .iter()
7003            .find(|e| e["event_type"] == "task_start")
7004            .unwrap();
7005        assert_eq!(failed[0]["parent_id"], task_start["event_id"]);
7006    }
7007
7008    /// The gateway timing keys: absent with no runtime gateway, `null` for a moment that never
7009    /// came, numbers for one that did.
7010    #[tokio::test]
7011    async fn gateway_timing_keys_follow_the_gateway() {
7012        let dir = tempfile::tempdir().unwrap();
7013        let mut w = make_writer(dir.path()).await;
7014        let failed = |turn, gateway| FailedInference {
7015            turn,
7016            error_code: INFERENCE_ERROR_DRIVER_FAILED,
7017            error: "stopped",
7018            provider_status: None,
7019            usage: None,
7020            origin: None,
7021            choice: None,
7022            message_ids: Vec::new(),
7023            tool_name: None,
7024            spent: None,
7025            settings: None,
7026            retry: RetryNote::default(),
7027            gateway,
7028        };
7029        w.write_failed_inference(failed(0, None)).await.unwrap();
7030        w.write_failed_inference(failed(1, Some(GatewayOffsets::default())))
7031            .await
7032            .unwrap();
7033        let answered = GatewayOffsets {
7034            reached_ms: Some(4),
7035            responded_ms: Some(90),
7036        };
7037        w.write_failed_inference(failed(2, Some(answered)))
7038            .await
7039            .unwrap();
7040        w.flush().await.unwrap();
7041
7042        let events = read_events(dir.path());
7043        assert!(events[0].get("gateway_reached_ms").is_none());
7044        assert!(events[0].get("upstream_responded_ms").is_none());
7045        assert_eq!(events[1]["gateway_reached_ms"], Value::Null);
7046        assert_eq!(events[1]["upstream_responded_ms"], Value::Null);
7047        assert!(events[1]
7048            .as_object()
7049            .unwrap()
7050            .contains_key("gateway_reached_ms"));
7051        assert!(events[1]
7052            .as_object()
7053            .unwrap()
7054            .contains_key("upstream_responded_ms"));
7055        assert_eq!(events[2]["gateway_reached_ms"], 4);
7056        assert_eq!(events[2]["upstream_responded_ms"], 90);
7057    }
7058
7059    /// A firing in the latch is written as one `call_deadline` line ahead of the next line the
7060    /// writer writes, inside the task and under the previous turn, and is not written twice. One
7061    /// from before the session frame follows `session_start`.
7062    #[tokio::test]
7063    async fn call_deadlines_are_drained_ahead_of_the_next_line() {
7064        let dir = tempfile::tempdir().unwrap();
7065        let mut w = make_writer(dir.path()).await;
7066        let deadlines = crate::limits::CallDeadlines::default();
7067        w.set_call_deadlines(deadlines.clone());
7068        let staged = |component: &str, call: &str| crate::limits::CallDeadline {
7069            component: component.to_string(),
7070            role: crate::limits::ComponentRole::Hook,
7071            call: call.to_string(),
7072            deadline_seconds: 2,
7073            elapsed_ms: 4_000,
7074        };
7075        deadlines.push_for_test(staged("stager", "on-stage"));
7076        w.write_session_start(None, None, Vec::new()).await.unwrap();
7077        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
7078            .await
7079            .unwrap();
7080        deadlines.push_for_test(crate::limits::CallDeadline {
7081            component: "stall-driver".to_string(),
7082            role: crate::limits::ComponentRole::Driver,
7083            call: "run".to_string(),
7084            deadline_seconds: 3,
7085            elapsed_ms: 5_001,
7086        });
7087        w.write_failed_inference(FailedInference {
7088            turn: 0,
7089            error_code: INFERENCE_ERROR_DRIVER_FAILED,
7090            error: "stopped",
7091            provider_status: None,
7092            usage: None,
7093            origin: None,
7094            choice: None,
7095            message_ids: Vec::new(),
7096            tool_name: None,
7097            spent: None,
7098            settings: None,
7099            retry: RetryNote::default(),
7100            gateway: Some(GatewayOffsets::default()),
7101        })
7102        .await
7103        .unwrap();
7104        w.write_session_end("ok").await.unwrap();
7105        w.flush().await.unwrap();
7106
7107        let events = read_events(dir.path());
7108        let types: Vec<&str> = events
7109            .iter()
7110            .map(|e| e["event_type"].as_str().unwrap())
7111            .collect();
7112        assert_eq!(
7113            types,
7114            [
7115                "session_start",
7116                "call_deadline",
7117                "task_start",
7118                "call_deadline",
7119                "inference",
7120                "session_end"
7121            ]
7122        );
7123        assert_eq!(events[1]["call"], "on-stage");
7124        assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
7125        assert!(events[1].get("task_id").is_none(), "outside a task");
7126        let cut = &events[3];
7127        assert_eq!(cut["component"], "stall-driver");
7128        assert_eq!(cut["role"], "driver");
7129        assert_eq!(cut["call"], "run");
7130        assert_eq!(cut["deadline_seconds"], 3);
7131        assert_eq!(cut["elapsed_ms"], 5_001);
7132        assert_eq!(cut["task_id"], "tsk_1");
7133        assert_eq!(cut["parent_id"], events[2]["event_id"]);
7134        assert!(cut["event_id"].as_str().is_some());
7135        assert!(cut["timestamp"].as_u64().is_some());
7136    }
7137
7138    #[tokio::test]
7139    async fn a_failed_hook_record_hangs_off_the_turn_and_keeps_hook_accounting() {
7140        let dir = tempfile::tempdir().unwrap();
7141        let mut w = make_writer(dir.path()).await;
7142        w.write_inference(
7143            0,
7144            Some(10),
7145            Some(5),
7146            "end_turn".to_string(),
7147            Some("end_turn"),
7148            None,
7149            None,
7150            None,
7151            Vec::new(),
7152            None,
7153            None,
7154        )
7155        .await
7156        .unwrap();
7157        let origin = InferenceOrigin {
7158            source: "hook:compactor".to_string(),
7159            model: "m".to_string(),
7160        };
7161        w.write_failed_inference(FailedInference {
7162            turn: 0,
7163            error_code: INFERENCE_ERROR_DRIVER_ERROR,
7164            error: "HTTP 500: boom",
7165            provider_status: Some(500),
7166            usage: None,
7167            origin: Some(&origin),
7168            choice: None,
7169            message_ids: Vec::new(),
7170            tool_name: None,
7171            spent: Some((Some(40), None)),
7172            settings: None,
7173            retry: RetryNote::default(),
7174            gateway: None,
7175        })
7176        .await
7177        .unwrap();
7178        assert_eq!(w.total_turns, 2);
7179        assert_eq!((w.total_input_tokens, w.total_output_tokens), (50, 5));
7180        w.flush().await.unwrap();
7181
7182        let events = read_events(dir.path());
7183        let turn = events.iter().find(|e| e["decision"] == "end_turn").unwrap();
7184        let hook = events.iter().find(|e| e["decision"] == "error").unwrap();
7185        assert_eq!(hook["parent_id"], turn["event_id"]);
7186        assert_eq!(hook["origin"], "hook:compactor");
7187        assert_eq!(hook["model"], "m");
7188        assert_eq!(hook["input_tokens"], 40);
7189        assert!(hook.get("output_tokens").is_none());
7190    }
7191
7192    /// Every `http` line names how its call was sent: the cap, the input budget and the driver
7193    /// config, every key present, `input_budget` written as `null` with no window and
7194    /// `driver_config` as `null` when the driver saw none.
7195    #[tokio::test]
7196    async fn inference_call_settings_are_written_on_every_kind_of_http_line() {
7197        let dir = tempfile::tempdir().unwrap();
7198        let mut w = make_writer(dir.path()).await;
7199        let turn = InferenceCallSettings {
7200            max_output_tokens: 4096,
7201            input_budget: Some(195_904),
7202            driver_config: Some(serde_json::json!({"reasoning_effort": "high"})),
7203        };
7204        let unconfigured = InferenceCallSettings {
7205            max_output_tokens: 1024,
7206            input_budget: None,
7207            driver_config: None,
7208        };
7209        let hook = InferenceCallSettings {
7210            max_output_tokens: 2000,
7211            input_budget: Some(198_000),
7212            driver_config: Some(serde_json::json!({"reasoning_effort": "low"})),
7213        };
7214        let origin = InferenceOrigin {
7215            source: "hook:compactor".to_string(),
7216            model: "m".to_string(),
7217        };
7218        w.write_turn_inference(
7219            0,
7220            Some(10),
7221            Some(5),
7222            "end_turn".to_string(),
7223            Some("end_turn"),
7224            None,
7225            None,
7226            Vec::new(),
7227            None,
7228            None,
7229            Some(&turn),
7230            RetryNote::default(),
7231            None,
7232        )
7233        .await
7234        .unwrap();
7235        w.write_turn_inference(
7236            1,
7237            Some(10),
7238            Some(5),
7239            "end_turn".to_string(),
7240            Some("end_turn"),
7241            None,
7242            None,
7243            Vec::new(),
7244            None,
7245            None,
7246            Some(&unconfigured),
7247            RetryNote::default(),
7248            None,
7249        )
7250        .await
7251        .unwrap();
7252        w.write_inference(
7253            1,
7254            Some(30),
7255            Some(8),
7256            "end_turn".to_string(),
7257            None,
7258            None,
7259            Some(&origin),
7260            None,
7261            Vec::new(),
7262            None,
7263            Some(&hook),
7264        )
7265        .await
7266        .unwrap();
7267        w.write_failed_inference(FailedInference {
7268            turn: 2,
7269            error_code: INFERENCE_ERROR_DRIVER_ERROR,
7270            error: "HTTP 500: boom",
7271            provider_status: Some(500),
7272            usage: None,
7273            origin: None,
7274            choice: None,
7275            message_ids: Vec::new(),
7276            tool_name: None,
7277            spent: None,
7278            settings: Some(&turn),
7279            retry: RetryNote::default(),
7280            gateway: None,
7281        })
7282        .await
7283        .unwrap();
7284        w.flush().await.unwrap();
7285
7286        let events = read_events(dir.path());
7287        let settings: Vec<(Value, Value, Value, Value)> = events
7288            .iter()
7289            .filter(|e| e["event_type"] == "inference")
7290            .map(|e| {
7291                (
7292                    e["decision"].clone(),
7293                    e["max_output_tokens"].clone(),
7294                    e.get("input_budget")
7295                        .cloned()
7296                        .expect("input_budget is written, null or not"),
7297                    e.get("driver_config")
7298                        .cloned()
7299                        .expect("driver_config is written, null or not"),
7300                )
7301            })
7302            .collect();
7303        assert_eq!(
7304            settings,
7305            [
7306                (
7307                    serde_json::json!("end_turn"),
7308                    serde_json::json!(4096),
7309                    serde_json::json!(195_904),
7310                    serde_json::json!({"reasoning_effort": "high"})
7311                ),
7312                (
7313                    serde_json::json!("end_turn"),
7314                    serde_json::json!(1024),
7315                    Value::Null,
7316                    Value::Null
7317                ),
7318                (
7319                    serde_json::json!("end_turn"),
7320                    serde_json::json!(2000),
7321                    serde_json::json!(198_000),
7322                    serde_json::json!({"reasoning_effort": "low"})
7323                ),
7324                (
7325                    serde_json::json!("error"),
7326                    serde_json::json!(4096),
7327                    serde_json::json!(195_904),
7328                    serde_json::json!({"reasoning_effort": "high"})
7329                ),
7330            ]
7331        );
7332    }
7333
7334    /// A line written without settings — every `process` line — carries none of the keys.
7335    #[tokio::test]
7336    async fn an_inference_line_without_call_settings_writes_neither_key() {
7337        let dir = tempfile::tempdir().unwrap();
7338        let mut w = make_writer(dir.path()).await;
7339        w.write_inference(
7340            0,
7341            Some(10),
7342            Some(5),
7343            "end_turn".to_string(),
7344            None,
7345            None,
7346            None,
7347            None,
7348            Vec::new(),
7349            None,
7350            None,
7351        )
7352        .await
7353        .unwrap();
7354        w.write_failed_inference(FailedInference {
7355            turn: 1,
7356            error_code: INFERENCE_ERROR_HARNESS_AUTH,
7357            error: "bad login",
7358            provider_status: None,
7359            usage: None,
7360            origin: None,
7361            choice: None,
7362            message_ids: Vec::new(),
7363            tool_name: None,
7364            spent: None,
7365            settings: None,
7366            retry: RetryNote::default(),
7367            gateway: None,
7368        })
7369        .await
7370        .unwrap();
7371        w.flush().await.unwrap();
7372
7373        let events = read_events(dir.path());
7374        let lines: Vec<&Value> = events
7375            .iter()
7376            .filter(|e| e["event_type"] == "inference")
7377            .collect();
7378        assert_eq!(lines.len(), 2);
7379        for line in lines {
7380            assert!(line.get("max_output_tokens").is_none(), "{line}");
7381            assert!(line.get("input_budget").is_none(), "{line}");
7382            assert!(line.get("driver_config").is_none(), "{line}");
7383        }
7384    }
7385
7386    fn driver_config_env(value: &str) -> Vec<(String, String)> {
7387        vec![
7388            ("MURMUR_INFERENCE_MODEL".to_string(), "gpt-5".to_string()),
7389            (
7390                "MURMUR_INFERENCE_DRIVER_CONFIG".to_string(),
7391                value.to_string(),
7392            ),
7393        ]
7394    }
7395
7396    fn redact_secret(text: &str) -> String {
7397        text.replace("sk-secret", crate::gateway_credential::REDACTED)
7398    }
7399
7400    #[test]
7401    fn recorded_driver_config_is_none_without_the_variable() {
7402        let env = vec![("MURMUR_INFERENCE_MODEL".to_string(), "gpt-5".to_string())];
7403        assert_eq!(recorded_driver_config(&env, redact_secret), None);
7404    }
7405
7406    #[test]
7407    fn recorded_driver_config_is_the_parsed_object() {
7408        assert_eq!(
7409            recorded_driver_config(
7410                &driver_config_env(r#"{"reasoning_effort":"low"}"#),
7411                redact_secret
7412            ),
7413            Some(serde_json::json!({"reasoning_effort": "low"}))
7414        );
7415    }
7416
7417    #[test]
7418    fn recorded_driver_config_redacts_keys_and_string_leaves_at_any_depth() {
7419        let env = driver_config_env(
7420            r#"{"note":"x-sk-secret-y","nested":{"list":["sk-secret"]},"sk-secret":1}"#,
7421        );
7422        assert_eq!(
7423            recorded_driver_config(&env, redact_secret),
7424            Some(serde_json::json!({
7425                "note": "x-[redacted]-y",
7426                "nested": {"list": ["[redacted]"]},
7427                "[redacted]": 1,
7428            }))
7429        );
7430    }
7431
7432    #[test]
7433    fn recorded_driver_config_replaces_a_number_the_redactor_changes() {
7434        let redact_pin = |text: &str| text.replace("424242", crate::gateway_credential::REDACTED);
7435        let env = driver_config_env(r#"{"pin":424242,"retries":3,"store":false,"seed":null}"#);
7436        assert_eq!(
7437            recorded_driver_config(&env, redact_pin),
7438            Some(serde_json::json!({
7439                "pin": "[redacted]",
7440                "retries": 3,
7441                "store": false,
7442                "seed": null,
7443            }))
7444        );
7445    }
7446
7447    #[test]
7448    fn recorded_driver_config_records_text_that_is_not_json_as_a_redacted_string() {
7449        assert_eq!(
7450            recorded_driver_config(&driver_config_env("effort=sk-secret"), redact_secret),
7451            Some(Value::String("effort=[redacted]".to_string()))
7452        );
7453    }
7454
7455    #[tokio::test]
7456    async fn tool_call_fields() {
7457        let dir = tempfile::tempdir().unwrap();
7458        let mut w = make_writer(dir.path()).await;
7459        w.write_tool_call(
7460            1,
7461            "bash".to_string(),
7462            None,
7463            serde_json::json!({"command": "echo hi"}),
7464            42,
7465            "hi\n",
7466            100,
7467            55,
7468            "ok".to_string(),
7469            None,
7470            None,
7471            None,
7472        )
7473        .await
7474        .unwrap();
7475        w.flush().await.unwrap();
7476
7477        let events = read_events(dir.path());
7478        let e = &events[0];
7479        assert_eq!(e["event_type"], "tool_call");
7480        assert_eq!(e["turn"], 1);
7481        assert_eq!(e["tool_name"], "bash");
7482        assert_eq!(e["input_bytes"], 42);
7483        assert_eq!(e["output_bytes"], 100);
7484        assert_eq!(e["duration_ms"], 55);
7485        assert_eq!(e["status"], "ok");
7486        assert!(
7487            e.get("state_effect").is_none(),
7488            "state_effect must be omitted when the tool declared nothing"
7489        );
7490    }
7491
7492    #[tokio::test]
7493    async fn tool_call_records_declared_state_effect() {
7494        let dir = tempfile::tempdir().unwrap();
7495        let mut w = make_writer(dir.path()).await;
7496        w.write_tool_call(
7497            0,
7498            "murmur-tool-editor".to_string(),
7499            None,
7500            serde_json::json!({"operation": "read_file", "path": "a.txt"}),
7501            10,
7502            "hi\n",
7503            3,
7504            5,
7505            "ok".to_string(),
7506            Some("read".to_string()),
7507            None,
7508            None,
7509        )
7510        .await
7511        .unwrap();
7512        w.flush().await.unwrap();
7513
7514        let events = read_events(dir.path());
7515        assert_eq!(
7516            events[0]["state_effect"], "read",
7517            "declared state_effect must be recorded verbatim on the tool_call event"
7518        );
7519    }
7520
7521    #[tokio::test]
7522    async fn tool_call_records_declared_resource_id() {
7523        let dir = tempfile::tempdir().unwrap();
7524        let mut w = make_writer(dir.path()).await;
7525        w.write_tool_call(
7526            0,
7527            "murmur-tool-code-graph".to_string(),
7528            None,
7529            serde_json::json!({"symbol": "Foo::bar"}),
7530            10,
7531            "hi\n",
7532            3,
7533            5,
7534            "ok".to_string(),
7535            Some("read".to_string()),
7536            Some("sym:Foo".to_string()),
7537            None,
7538        )
7539        .await
7540        .unwrap();
7541        w.flush().await.unwrap();
7542
7543        let events = read_events(dir.path());
7544        assert_eq!(
7545            events[0]["resource_id"], "sym:Foo",
7546            "declared resource_id must reach trace.jsonl verbatim, unparsed"
7547        );
7548    }
7549
7550    #[tokio::test]
7551    async fn tool_call_omits_undeclared_resource_id() {
7552        let dir = tempfile::tempdir().unwrap();
7553        let mut w = make_writer(dir.path()).await;
7554        w.write_tool_call(
7555            0,
7556            "bash".to_string(),
7557            None,
7558            serde_json::json!({"command": "echo hi"}),
7559            10,
7560            "hi\n",
7561            3,
7562            5,
7563            "ok".to_string(),
7564            None,
7565            None,
7566            None,
7567        )
7568        .await
7569        .unwrap();
7570        w.flush().await.unwrap();
7571
7572        let events = read_events(dir.path());
7573        assert!(
7574            events[0].get("resource_id").is_none(),
7575            "resource_id must be omitted entirely (not null) when the tool declared nothing"
7576        );
7577    }
7578
7579    #[tokio::test]
7580    async fn shell_fields() {
7581        let dir = tempfile::tempdir().unwrap();
7582        let mut w = make_writer(dir.path()).await;
7583        w.write_shell(
7584            0,
7585            "/usr/bin/echo".to_string(),
7586            "echo hi".to_string(),
7587            0,
7588            7,
7589            0,
7590            10,
7591            None,
7592        )
7593        .await
7594        .unwrap();
7595        w.flush().await.unwrap();
7596
7597        let events = read_events(dir.path());
7598        let e = &events[0];
7599        assert_eq!(e["event_type"], "shell");
7600        // `binary` names what ran; `command` still carries only the argument list, so
7601        // neither can be derived from the other.
7602        assert_eq!(e["binary"], "/usr/bin/echo");
7603        assert_eq!(e["command"], "echo hi");
7604        assert_eq!(e["exit_code"], 0);
7605        assert_eq!(e["stdout_bytes"], 7);
7606        assert_eq!(e["stderr_bytes"], 0);
7607        assert_eq!(e["duration_ms"], 10);
7608    }
7609
7610    #[tokio::test]
7611    async fn compaction_fields() {
7612        let dir = tempfile::tempdir().unwrap();
7613        let mut w = make_writer(dir.path()).await;
7614        w.write_compaction(3, 80000, OccupancyBasis::Actual, 20000)
7615            .await
7616            .unwrap();
7617        w.write_compaction(4, 30000, OccupancyBasis::Estimate, 10000)
7618            .await
7619            .unwrap();
7620        w.flush().await.unwrap();
7621
7622        let events = read_events(dir.path());
7623        let e = &events[0];
7624        assert_eq!(e["event_type"], "compaction");
7625        assert_eq!(e["turn"], 3);
7626        assert_eq!(e["tokens_before"], 80000);
7627        assert_eq!(e["tokens_before_source"], "actual");
7628        assert_eq!(e["tokens_after"], 20000);
7629        assert_eq!(events[1]["tokens_before_source"], "estimate");
7630    }
7631
7632    #[tokio::test]
7633    async fn hook_dispatch_error_fields() {
7634        let dir = tempfile::tempdir().unwrap();
7635        let mut w = make_writer(dir.path()).await;
7636        w.write_hook_dispatch_error("my-hook", "on-tool-call", "write-manifests")
7637            .await
7638            .unwrap();
7639        w.flush().await.unwrap();
7640
7641        let events = read_events(dir.path());
7642        let e = &events[0];
7643        assert_eq!(e["event_type"], "hook_dispatch_error");
7644        assert_eq!(e["session_id"], "test-session-id");
7645        assert_eq!(e["hook_name"], "my-hook");
7646        assert_eq!(e["event"], "on-tool-call");
7647        assert_eq!(e["arm"], "write-manifests");
7648        assert!(e["timestamp"].as_u64().unwrap() > 0);
7649        // snake_case checks
7650        assert!(e.get("hookName").is_none(), "must use snake_case");
7651    }
7652
7653    #[tokio::test]
7654    async fn session_end_fields_and_snake_case() {
7655        let dir = tempfile::tempdir().unwrap();
7656        let mut w = make_writer(dir.path()).await;
7657        w.write_inference(
7658            0,
7659            Some(100),
7660            Some(50),
7661            "tool_call".to_string(),
7662            Some("tool_call"),
7663            None,
7664            None,
7665            None,
7666            Vec::new(),
7667            None,
7668            None,
7669        )
7670        .await
7671        .unwrap();
7672        w.write_tool_call(
7673            0,
7674            "bash".to_string(),
7675            None,
7676            serde_json::json!({"command": "ls"}),
7677            10,
7678            "file.txt\n",
7679            20,
7680            5,
7681            "ok".to_string(),
7682            None,
7683            None,
7684            None,
7685        )
7686        .await
7687        .unwrap();
7688        w.write_shell(0, "/bin/ls".to_string(), "ls".to_string(), 0, 3, 0, 2, None)
7689            .await
7690            .unwrap();
7691        w.write_session_end("ok").await.unwrap();
7692        w.flush().await.unwrap();
7693
7694        let events = read_events(dir.path());
7695        let se = events.last().unwrap();
7696        assert_eq!(se["event_type"], "session_end");
7697        assert_eq!(se["total_turns"], 1);
7698        assert_eq!(se["total_input_tokens"], 100);
7699        assert_eq!(se["total_output_tokens"], 50);
7700        assert_eq!(se["total_tool_calls"], 1);
7701        assert_eq!(se["total_shell_calls"], 1);
7702        assert_eq!(se["exit_status"], "ok");
7703        assert!(se["duration_ms"].as_u64().is_some());
7704        // snake_case checks
7705        assert!(se.get("exitStatus").is_none(), "must use snake_case");
7706        assert!(se.get("totalTurns").is_none(), "must use snake_case");
7707    }
7708
7709    #[tokio::test]
7710    async fn write_session_end_if_not_ended_is_noop_when_already_ended() {
7711        let dir = tempfile::tempdir().unwrap();
7712        let mut w = make_writer(dir.path()).await;
7713        w.write_session_start(Some(10), None, vec![]).await.unwrap();
7714        w.write_session_end("ok").await.unwrap();
7715        w.write_session_end_if_not_ended("failed").await.unwrap();
7716        w.flush().await.unwrap();
7717
7718        let events = read_events(dir.path());
7719        assert_eq!(
7720            events
7721                .iter()
7722                .filter(|e| e["event_type"] == "session_end")
7723                .count(),
7724            1,
7725            "session_end should appear exactly once"
7726        );
7727    }
7728
7729    #[tokio::test]
7730    async fn write_session_end_if_not_ended_writes_when_started_not_ended() {
7731        let dir = tempfile::tempdir().unwrap();
7732        let mut w = make_writer(dir.path()).await;
7733        w.write_session_start(Some(10), None, vec![]).await.unwrap();
7734        // Simulate error path — session_end_if_not_ended in launch_session
7735        w.write_session_end_if_not_ended("failed").await.unwrap();
7736        w.flush().await.unwrap();
7737
7738        let events = read_events(dir.path());
7739        let se = events
7740            .iter()
7741            .find(|e| e["event_type"] == "session_end")
7742            .unwrap();
7743        assert_eq!(se["exit_status"], "failed");
7744    }
7745
7746    #[tokio::test]
7747    async fn no_session_end_written_when_session_never_started() {
7748        let dir = tempfile::tempdir().unwrap();
7749        let mut w = make_writer(dir.path()).await;
7750        // session_start never written — fallback should be no-op
7751        w.write_session_end_if_not_ended("failed").await.unwrap();
7752        w.flush().await.unwrap();
7753
7754        let events = read_events(dir.path());
7755        assert!(
7756            events.iter().all(|e| e["event_type"] != "session_end"),
7757            "session_end must not appear when session_start was never written"
7758        );
7759    }
7760
7761    #[tokio::test]
7762    async fn session_id_on_every_event() {
7763        let dir = tempfile::tempdir().unwrap();
7764        let mut w = make_writer(dir.path()).await;
7765        w.write_session_start(Some(10), None, vec!["bash".to_string()])
7766            .await
7767            .unwrap();
7768        w.write_inference(
7769            0,
7770            Some(10),
7771            Some(5),
7772            "end_turn".to_string(),
7773            Some("end_turn"),
7774            None,
7775            None,
7776            None,
7777            Vec::new(),
7778            None,
7779            None,
7780        )
7781        .await
7782        .unwrap();
7783        w.write_session_end("ok").await.unwrap();
7784        w.flush().await.unwrap();
7785
7786        let events = read_events(dir.path());
7787        for e in &events {
7788            assert_eq!(e["session_id"], "test-session-id");
7789        }
7790    }
7791
7792    #[tokio::test]
7793    async fn counts_match_per_event_sum() {
7794        let dir = tempfile::tempdir().unwrap();
7795        let mut w = make_writer(dir.path()).await;
7796        w.write_session_start(Some(10), None, vec![]).await.unwrap();
7797        w.write_inference(
7798            0,
7799            Some(50),
7800            Some(25),
7801            "tool_call".to_string(),
7802            Some("tool_call"),
7803            Some("bash".to_string()),
7804            None,
7805            None,
7806            vec!["msg_0000000000000000000000000000000a".to_string()],
7807            None,
7808            None,
7809        )
7810        .await
7811        .unwrap();
7812        w.write_tool_call(
7813            0,
7814            "bash".to_string(),
7815            None,
7816            serde_json::json!({"command": "echo"}),
7817            5,
7818            "ok",
7819            10,
7820            3,
7821            "ok".to_string(),
7822            None,
7823            None,
7824            None,
7825        )
7826        .await
7827        .unwrap();
7828        w.write_shell(
7829            0,
7830            "/bin/echo".to_string(),
7831            "echo".to_string(),
7832            0,
7833            4,
7834            0,
7835            1,
7836            None,
7837        )
7838        .await
7839        .unwrap();
7840        w.write_inference(
7841            1,
7842            Some(60),
7843            Some(30),
7844            "end_turn".to_string(),
7845            Some("end_turn"),
7846            None,
7847            None,
7848            None,
7849            Vec::new(),
7850            None,
7851            None,
7852        )
7853        .await
7854        .unwrap();
7855        w.write_session_end("ok").await.unwrap();
7856        w.flush().await.unwrap();
7857
7858        let events = read_events(dir.path());
7859        let inference_count = events
7860            .iter()
7861            .filter(|e| e["event_type"] == "inference")
7862            .count();
7863        let tool_count = events
7864            .iter()
7865            .filter(|e| e["event_type"] == "tool_call")
7866            .count();
7867        let shell_count = events.iter().filter(|e| e["event_type"] == "shell").count();
7868
7869        let se = events
7870            .iter()
7871            .find(|e| e["event_type"] == "session_end")
7872            .unwrap();
7873        assert_eq!(
7874            se["total_turns"].as_u64().unwrap() as usize,
7875            inference_count
7876        );
7877        assert_eq!(
7878            se["total_tool_calls"].as_u64().unwrap() as usize,
7879            tool_count
7880        );
7881        assert_eq!(
7882            se["total_shell_calls"].as_u64().unwrap() as usize,
7883            shell_count
7884        );
7885    }
7886
7887    #[tokio::test]
7888    async fn tool_call_input_always_present() {
7889        let dir = tempfile::tempdir().unwrap();
7890        let mut w = make_writer(dir.path()).await; // TraceCapture::Meta
7891        w.write_tool_call(
7892            0,
7893            "bash".to_string(),
7894            None,
7895            serde_json::json!({"command": "ls -la"}),
7896            20,
7897            "total 8\n",
7898            80,
7899            12,
7900            "ok".to_string(),
7901            None,
7902            None,
7903            None,
7904        )
7905        .await
7906        .unwrap();
7907        w.flush().await.unwrap();
7908
7909        let events = read_events(dir.path());
7910        let e = &events[0];
7911        assert_eq!(e["event_type"], "tool_call");
7912        assert_eq!(e["input"]["command"], "ls -la");
7913        assert!(e.get("input").is_some(), "input must always be present");
7914    }
7915
7916    /// The `tool_call` record `w` writes for one failed call carrying `error`, read back.
7917    async fn tool_call_with_error(
7918        dir: &std::path::Path,
7919        capture: TraceCapture,
7920        error: Option<&str>,
7921    ) -> Value {
7922        let mut w = make_writer_with_opts(dir, capture).await;
7923        w.write_tool_call(
7924            0,
7925            "tool-fixture".to_string(),
7926            Some("toolu_1".to_string()),
7927            serde_json::json!({}),
7928            2,
7929            "native tool execution failed",
7930            28,
7931            3,
7932            "error".to_string(),
7933            None,
7934            None,
7935            error,
7936        )
7937        .await
7938        .unwrap();
7939        w.flush().await.unwrap();
7940        read_events(dir).remove(0)
7941    }
7942
7943    #[tokio::test]
7944    async fn tool_call_error_is_written_without_output_under_meta() {
7945        let dir = tempfile::tempdir().unwrap();
7946        let text = "sandbox: native tool enforcement setup failed before exec: Landlock: \
7947                    Too many open files (os error 24)";
7948        let e = tool_call_with_error(dir.path(), TraceCapture::Meta, Some(text)).await;
7949        assert_eq!(e["error"], text);
7950        assert!(e.get("output").is_none(), "meta stores no output: {e}");
7951    }
7952
7953    #[tokio::test]
7954    async fn tool_call_error_is_written_under_every_capture() {
7955        for capture in [
7956            TraceCapture::None,
7957            TraceCapture::Meta,
7958            TraceCapture::Content,
7959        ] {
7960            let dir = tempfile::tempdir().unwrap();
7961            let e = tool_call_with_error(dir.path(), capture, Some("spawn failed")).await;
7962            assert_eq!(e["error"], "spawn failed", "{capture:?}: {e}");
7963        }
7964    }
7965
7966    #[tokio::test]
7967    async fn tool_call_error_is_capped_on_a_char_boundary() {
7968        let dir = tempfile::tempdir().unwrap();
7969        // 3,000 characters: one ASCII byte, then two-byte characters, so the cap's byte offset
7970        // falls inside a character.
7971        let long = format!("a{}", "é".repeat(2_999));
7972        assert_eq!(long.chars().count(), 3_000);
7973        let e = tool_call_with_error(dir.path(), TraceCapture::Meta, Some(&long)).await;
7974        let error = e["error"].as_str().unwrap();
7975        assert!(error.len() <= MAX_TASK_FAILURE_REASON_BYTES);
7976        assert!(error.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
7977        assert!(long.starts_with(error));
7978    }
7979
7980    #[tokio::test]
7981    async fn tool_call_error_redacts_a_peer_handle() {
7982        let dir = tempfile::tempdir().unwrap();
7983        let token = crate::peer_handoff::mint(
7984            &crate::peer_handoff::PeerMintKey::generate().unwrap(),
7985            &crate::peer_handoff::HandlePayload {
7986                v: 1,
7987                iss: "ses_1".to_string(),
7988                p: "report.md".to_string(),
7989                exp: timestamp_ms() + 3_600_000,
7990                n: "0123456789abcdef0123456789abcdef".to_string(),
7991            },
7992            "a@b",
7993        )
7994        .unwrap();
7995        let text = format!("fetch-peer-file {token} failed");
7996        let e = tool_call_with_error(dir.path(), TraceCapture::Meta, Some(&text)).await;
7997        let id = crate::peer_handoff::handle_id(&token);
7998        assert_eq!(e["error"], format!("fetch-peer-file <handle:{id}> failed"));
7999    }
8000
8001    #[tokio::test]
8002    async fn tool_call_without_an_error_has_no_error_key() {
8003        let dir = tempfile::tempdir().unwrap();
8004        let e = tool_call_with_error(dir.path(), TraceCapture::Meta, None).await;
8005        assert!(e.get("error").is_none(), "{e}");
8006    }
8007
8008    #[tokio::test]
8009    async fn tool_call_output_absent_by_default() {
8010        let dir = tempfile::tempdir().unwrap();
8011        let mut w = make_writer(dir.path()).await; // TraceCapture::Meta
8012        w.write_tool_call(
8013            0,
8014            "bash".to_string(),
8015            None,
8016            serde_json::json!({"command": "ls"}),
8017            10,
8018            "file.txt\n",
8019            90,
8020            8,
8021            "ok".to_string(),
8022            None,
8023            None,
8024            None,
8025        )
8026        .await
8027        .unwrap();
8028        w.flush().await.unwrap();
8029
8030        let events = read_events(dir.path());
8031        let e = &events[0];
8032        assert!(
8033            e.get("output").is_none(),
8034            "output must be absent under a capture mode that stores no bodies"
8035        );
8036        assert_eq!(e["output_bytes"], 90, "output_bytes must still be recorded");
8037    }
8038
8039    #[tokio::test]
8040    async fn skill_call_writes_skill_call_event() {
8041        let dir = tempfile::tempdir().unwrap();
8042        let mut w = make_writer(dir.path()).await;
8043        w.write_skill_call(
8044            2,
8045            "my-skill".to_string(),
8046            &LockOrigin::Operator,
8047            1024,
8048            8,
8049            "ok".to_string(),
8050        )
8051        .await
8052        .unwrap();
8053        w.flush().await.unwrap();
8054
8055        let events = read_events(dir.path());
8056        let e = &events[0];
8057        assert_eq!(e["event_type"], "skill_call");
8058        assert_eq!(e["turn"], 2);
8059        assert_eq!(e["skill_name"], "my-skill");
8060        assert_eq!(e["output_bytes"], 1024);
8061        assert_eq!(e["duration_ms"], 8);
8062        assert_eq!(e["status"], "ok");
8063    }
8064
8065    #[tokio::test]
8066    async fn skill_call_does_not_increment_total_tool_calls() {
8067        let dir = tempfile::tempdir().unwrap();
8068        let mut w = make_writer(dir.path()).await;
8069        w.write_session_start(Some(10), None, vec![]).await.unwrap();
8070        w.write_tool_call(
8071            0,
8072            "real-tool".to_string(),
8073            None,
8074            serde_json::json!({}),
8075            2,
8076            "data",
8077            4,
8078            5,
8079            "ok".to_string(),
8080            None,
8081            None,
8082            None,
8083        )
8084        .await
8085        .unwrap();
8086        w.write_skill_call(
8087            1,
8088            "my-skill".to_string(),
8089            &LockOrigin::Operator,
8090            512,
8091            3,
8092            "ok".to_string(),
8093        )
8094        .await
8095        .unwrap();
8096        w.write_session_end("ok").await.unwrap();
8097        w.flush().await.unwrap();
8098
8099        let events = read_events(dir.path());
8100        let se = events
8101            .iter()
8102            .find(|e| e["event_type"] == "session_end")
8103            .unwrap();
8104        assert_eq!(
8105            se["total_tool_calls"], 1,
8106            "skill_call must not inflate total_tool_calls"
8107        );
8108    }
8109
8110    #[tokio::test]
8111    async fn tool_call_output_present_when_opted_in() {
8112        let dir = tempfile::tempdir().unwrap();
8113        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
8114        w.write_tool_call(
8115            0,
8116            "bash".to_string(),
8117            None,
8118            serde_json::json!({"command": "echo hello"}),
8119            22,
8120            "hello\n",
8121            60,
8122            5,
8123            "ok".to_string(),
8124            None,
8125            None,
8126            None,
8127        )
8128        .await
8129        .unwrap();
8130        w.flush().await.unwrap();
8131
8132        let events = read_events(dir.path());
8133        let e = &events[0];
8134        assert_eq!(
8135            e["output"].as_str().unwrap(),
8136            "hello\n",
8137            "output must be present under capture: content"
8138        );
8139        assert_eq!(e["output_bytes"], 60);
8140    }
8141
8142    #[tokio::test]
8143    async fn task_end_carries_reopen_count() {
8144        let dir = tempfile::tempdir().unwrap();
8145        let mut w = make_writer(dir.path()).await;
8146        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8147            .await
8148            .unwrap();
8149        w.write_task_end("tsk_1", "ok", 2, Vec::new(), None, None)
8150            .await
8151            .unwrap();
8152        w.flush().await.unwrap();
8153
8154        let events = read_events(dir.path());
8155        let end = events
8156            .iter()
8157            .find(|e| e["event_type"] == "task_end")
8158            .unwrap();
8159        assert_eq!(end["task_id"], "tsk_1");
8160        assert_eq!(end["exit_status"], "ok");
8161        assert_eq!(end["reopen_count"], 2);
8162        assert_eq!(
8163            end["capped_turns"], 0,
8164            "always present, 0 when no turn was capped"
8165        );
8166    }
8167
8168    /// `task_end.capped_turns` counts the task's agent-loop `inference` lines with
8169    /// `stop_reason: "max_tokens"`, and a new task starts it at 0.
8170    #[tokio::test]
8171    async fn task_end_counts_capped_turns_per_task() {
8172        let dir = tempfile::tempdir().unwrap();
8173        let mut w = make_writer(dir.path()).await;
8174        for task in ["tsk_1", "tsk_2"] {
8175            w.write_task_start(task, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8176                .await
8177                .unwrap();
8178            let stops: &[&str] = if task == "tsk_1" {
8179                &["max_tokens", "tool_call", "max_tokens", "end_turn"]
8180            } else {
8181                &["end_turn"]
8182            };
8183            for (turn, stop) in stops.iter().enumerate() {
8184                let decision = if *stop == "max_tokens" {
8185                    "capped"
8186                } else {
8187                    stop
8188                };
8189                w.write_turn_inference(
8190                    u32::try_from(turn).unwrap(),
8191                    Some(10),
8192                    Some(5),
8193                    decision.to_string(),
8194                    Some(stop),
8195                    None,
8196                    None,
8197                    Vec::new(),
8198                    None,
8199                    None,
8200                    None,
8201                    RetryNote::default(),
8202                    None,
8203                )
8204                .await
8205                .unwrap();
8206            }
8207            w.write_task_end(task, "ok", 0, Vec::new(), None, None)
8208                .await
8209                .unwrap();
8210        }
8211        w.flush().await.unwrap();
8212
8213        let capped: Vec<_> = read_events(dir.path())
8214            .iter()
8215            .filter(|e| e["event_type"] == "task_end")
8216            .map(|e| e["capped_turns"].clone())
8217            .collect();
8218        assert_eq!(capped, vec![serde_json::json!(2), serde_json::json!(0)]);
8219    }
8220
8221    /// `task_end.keep_working` is the summary the runtime passes, `stopped_by` `null` when unset,
8222    /// and absent when the capsule does not set `lifecycle.keep_working`.
8223    #[tokio::test]
8224    async fn task_end_carries_keep_working_only_when_set() {
8225        let dir = tempfile::tempdir().unwrap();
8226        let mut w = make_writer(dir.path()).await;
8227        for (task_id, keep_working) in [
8228            (
8229                "tsk_1",
8230                Some(KeepWorkingSummary {
8231                    used: 2,
8232                    limit: 2,
8233                    stopped_by: Some("limit"),
8234                }),
8235            ),
8236            (
8237                "tsk_2",
8238                Some(KeepWorkingSummary {
8239                    used: 1,
8240                    limit: 5,
8241                    stopped_by: None,
8242                }),
8243            ),
8244            ("tsk_3", None),
8245        ] {
8246            w.write_task_start(task_id, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8247                .await
8248                .unwrap();
8249            w.write_task_end(task_id, "ok", 0, Vec::new(), keep_working, None)
8250                .await
8251                .unwrap();
8252        }
8253        w.flush().await.unwrap();
8254
8255        let events = read_events(dir.path());
8256        let ends: Vec<_> = events
8257            .iter()
8258            .filter(|e| e["event_type"] == "task_end")
8259            .collect();
8260        assert_eq!(
8261            ends[0]["keep_working"],
8262            serde_json::json!({"used": 2, "limit": 2, "stopped_by": "limit"})
8263        );
8264        assert_eq!(
8265            ends[1]["keep_working"],
8266            serde_json::json!({"used": 1, "limit": 5, "stopped_by": null})
8267        );
8268        assert!(ends[2].get("keep_working").is_none(), "{}", ends[2]);
8269    }
8270
8271    /// One `keep_working` record per continuation, parented on the task, and the session-status
8272    /// count follows it.
8273    #[tokio::test]
8274    async fn keep_working_record_carries_its_fields_and_feeds_session_status() {
8275        let dir = tempfile::tempdir().unwrap();
8276        let mut w = make_writer(dir.path()).await;
8277        let status = std::sync::Arc::new(crate::session_status::SessionStatus::new(
8278            std::time::Instant::now(),
8279            false,
8280            None,
8281            None,
8282            0,
8283            Some(3),
8284            None,
8285        ));
8286        w.set_session_status(std::sync::Arc::clone(&status));
8287        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8288            .await
8289            .unwrap();
8290        w.write_keep_working("tsk_1", 1, 3, Some(4), 66)
8291            .await
8292            .unwrap();
8293        w.write_keep_working("tsk_1", 2, 3, None, 66).await.unwrap();
8294        w.flush().await.unwrap();
8295
8296        let events = read_events(dir.path());
8297        let start = events
8298            .iter()
8299            .find(|e| e["event_type"] == "task_start")
8300            .unwrap();
8301        let records: Vec<_> = events
8302            .iter()
8303            .filter(|e| e["event_type"] == "keep_working")
8304            .collect();
8305        assert_eq!(records.len(), 2);
8306        for (record, (number, turns)) in records
8307            .iter()
8308            .zip([(1, serde_json::json!(4)), (2, serde_json::Value::Null)])
8309        {
8310            assert_eq!(record["task_id"], "tsk_1");
8311            assert_eq!(record["number"], number);
8312            assert_eq!(record["limit"], 3);
8313            assert_eq!(record["turns_remaining"], turns);
8314            assert_eq!(record["message_bytes"], 66);
8315            assert_eq!(record["parent_id"], start["event_id"]);
8316            assert!(record["event_id"].is_string());
8317        }
8318        let spend = crate::session_status::SpendReading {
8319            used: 0,
8320            ceiling: None,
8321            machine: None,
8322        };
8323        assert_eq!(
8324            serde_json::to_value(status.snapshot(spend)).unwrap()["keep_working"],
8325            serde_json::json!({"used": 2, "left": 1})
8326        );
8327    }
8328
8329    /// `task_end.done_check` is the summary the runtime passes and is absent with the option off.
8330    #[tokio::test]
8331    async fn task_end_carries_done_check_only_when_set() {
8332        let dir = tempfile::tempdir().unwrap();
8333        let mut w = make_writer(dir.path()).await;
8334        for (task_id, done_check) in [
8335            (
8336                "tsk_1",
8337                Some(DoneCheckSummary {
8338                    runs: 3,
8339                    reopens: 2,
8340                    limit: 2,
8341                    met: Some(false),
8342                    stopped_by: Some("limit"),
8343                }),
8344            ),
8345            (
8346                "tsk_2",
8347                Some(DoneCheckSummary {
8348                    runs: 0,
8349                    reopens: 0,
8350                    limit: 1,
8351                    met: None,
8352                    stopped_by: None,
8353                }),
8354            ),
8355            ("tsk_3", None),
8356        ] {
8357            w.write_task_start(task_id, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8358                .await
8359                .unwrap();
8360            w.write_task_end(task_id, "ok", 0, Vec::new(), None, done_check)
8361                .await
8362                .unwrap();
8363        }
8364        w.flush().await.unwrap();
8365
8366        let events = read_events(dir.path());
8367        let ends: Vec<_> = events
8368            .iter()
8369            .filter(|e| e["event_type"] == "task_end")
8370            .collect();
8371        assert_eq!(
8372            ends[0]["done_check"],
8373            serde_json::json!({"runs": 3, "reopens": 2, "limit": 2, "met": false,
8374                               "stopped_by": "limit"})
8375        );
8376        assert_eq!(
8377            ends[1]["done_check"],
8378            serde_json::json!({"runs": 0, "reopens": 0, "limit": 1, "met": null,
8379                               "stopped_by": null})
8380        );
8381        assert!(ends[2].get("done_check").is_none(), "{}", ends[2]);
8382    }
8383
8384    /// One `done_check` record per run, parented on the task, with its fields in the documented
8385    /// order; a reopening run feeds the session-status count and a non-reopening one does not.
8386    #[tokio::test]
8387    async fn done_check_record_carries_its_fields_and_feeds_session_status() {
8388        let dir = tempfile::tempdir().unwrap();
8389        let mut w = make_writer(dir.path()).await;
8390        let status = std::sync::Arc::new(crate::session_status::SessionStatus::new(
8391            std::time::Instant::now(),
8392            false,
8393            None,
8394            None,
8395            0,
8396            None,
8397            Some(3),
8398        ));
8399        w.set_session_status(std::sync::Arc::clone(&status));
8400        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8401            .await
8402            .unwrap();
8403        let mut run = DoneCheckRun {
8404            task_id: "tsk_1",
8405            number: 1,
8406            command: "bash -c 'exit 1'",
8407            outcome: "failed",
8408            exit_code: Some(1),
8409            duration_ms: 12,
8410            stdout_bytes: 5,
8411            stderr_bytes: 0,
8412            truncated: false,
8413            resource_limit: None,
8414            reopened: true,
8415            reopens_left: 2,
8416            turns_remaining: Some(4),
8417        };
8418        w.write_done_check(&run, 1).await.unwrap();
8419        run.number = 2;
8420        run.outcome = "timed_out";
8421        run.exit_code = None;
8422        run.reopened = false;
8423        run.turns_remaining = None;
8424        w.write_done_check(&run, 1).await.unwrap();
8425        w.flush().await.unwrap();
8426
8427        let text = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
8428        let line = text
8429            .lines()
8430            .find(|line| line.contains("\"done_check\""))
8431            .unwrap();
8432        let order: Vec<usize> = [
8433            "event_type",
8434            "event_id",
8435            "parent_id",
8436            "session_id",
8437            "timestamp",
8438            "task_id",
8439            "number",
8440            "command",
8441            "outcome",
8442            "exit_code",
8443            "duration_ms",
8444            "stdout_bytes",
8445            "stderr_bytes",
8446            "truncated",
8447            "resource_limit",
8448            "reopened",
8449            "reopens_left",
8450            "turns_remaining",
8451        ]
8452        .iter()
8453        .map(|key| line.find(&format!("\"{key}\":")).unwrap())
8454        .collect();
8455        assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "{line}");
8456
8457        let events = read_events(dir.path());
8458        let start = events
8459            .iter()
8460            .find(|e| e["event_type"] == "task_start")
8461            .unwrap();
8462        let records: Vec<_> = events
8463            .iter()
8464            .filter(|e| e["event_type"] == "done_check")
8465            .collect();
8466        assert_eq!(records.len(), 2);
8467        assert_eq!(records[0]["parent_id"], start["event_id"]);
8468        assert_eq!(records[0]["command"], "bash -c 'exit 1'");
8469        assert_eq!(records[0]["exit_code"], 1);
8470        assert_eq!(records[0]["reopened"], true);
8471        assert_eq!(records[0]["turns_remaining"], 4);
8472        assert_eq!(records[1]["outcome"], "timed_out");
8473        assert_eq!(records[1]["exit_code"], serde_json::Value::Null);
8474        assert_eq!(records[1]["resource_limit"], serde_json::Value::Null);
8475        assert_eq!(records[1]["turns_remaining"], serde_json::Value::Null);
8476        let spend = crate::session_status::SpendReading {
8477            used: 0,
8478            ceiling: None,
8479            machine: None,
8480        };
8481        assert_eq!(
8482            serde_json::to_value(status.snapshot(spend)).unwrap()["done_check"],
8483            serde_json::json!({"used": 1, "left": 2})
8484        );
8485    }
8486
8487    /// `task_end` names the background commands still running, and has no such field when there
8488    /// were none.
8489    #[tokio::test]
8490    async fn task_end_names_the_commands_still_running() {
8491        let dir = tempfile::tempdir().unwrap();
8492        let mut w = make_writer(dir.path()).await;
8493        for (task_id, running) in [
8494            ("tsk_1", vec!["wrk_0a".to_string(), "wrk_0b".to_string()]),
8495            ("tsk_2", Vec::new()),
8496        ] {
8497            w.write_task_start(task_id, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8498                .await
8499                .unwrap();
8500            w.write_task_end(task_id, "ok", 0, running, None, None)
8501                .await
8502                .unwrap();
8503        }
8504        w.flush().await.unwrap();
8505
8506        let events = read_events(dir.path());
8507        let ends: Vec<_> = events
8508            .iter()
8509            .filter(|e| e["event_type"] == "task_end")
8510            .collect();
8511        assert_eq!(
8512            ends[0]["running_commands"],
8513            serde_json::json!(["wrk_0a", "wrk_0b"])
8514        );
8515        assert!(ends[1].get("running_commands").is_none(), "{}", ends[1]);
8516    }
8517
8518    /// The two writers of `shell_completed` build one record: a completion a `background-command`
8519    /// call delivered carries no `completion_task_id`, one queued as a task names it, and both
8520    /// carry when the command exited and which way it reached the agent.
8521    #[tokio::test]
8522    async fn shell_completed_names_its_delivery_and_exit_time() {
8523        let dir = tempfile::tempdir().unwrap();
8524        let mut w = make_writer(dir.path()).await;
8525        let appender = ResourceTraceAppender::open(
8526            &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
8527            "test-session-id".to_string(),
8528            w.session_event_id().to_string(),
8529        )
8530        .await
8531        .unwrap();
8532        let completion = crate::detached::DetachedCompletion {
8533            work_id: "wrk_0a".to_string(),
8534            binary: "/usr/bin/bash".to_string(),
8535            command: "sleep 3".to_string(),
8536            exit_code: 0,
8537            duration_ms: 3_000,
8538            exited_at_ms: 1_750,
8539            output_path: "/records/logs/wrk_0a.log".to_string(),
8540            output_bytes: 10,
8541            resource_limit: None,
8542            output_holders: crate::shell::OutputHolders::Closed,
8543            context_id: "ctx_1".to_string(),
8544            provenance: crate::origin::TaskProvenance::derive(
8545                crate::origin::TaskOrigin::Completion,
8546                None,
8547            ),
8548            error: None,
8549        };
8550        w.write_session_start(None, None, Vec::new()).await.unwrap();
8551        w.flush().await.unwrap();
8552        appender.write_shell_completed(&completion).await;
8553        w.write_shell_completed(&completion, "tsk_2").await.unwrap();
8554        w.flush().await.unwrap();
8555
8556        let events = read_events(dir.path());
8557        let completed: Vec<_> = events
8558            .iter()
8559            .filter(|e| e["event_type"] == "shell_completed")
8560            .collect();
8561        assert_eq!(completed.len(), 2);
8562        assert_eq!(completed[0]["delivered_via"], "background_command");
8563        assert!(
8564            completed[0].get("completion_task_id").is_none(),
8565            "{}",
8566            completed[0]
8567        );
8568        assert_eq!(completed[1]["delivered_via"], "task");
8569        assert_eq!(completed[1]["completion_task_id"], "tsk_2");
8570        for record in &completed {
8571            assert_eq!(record["exited_at_ms"], 1_750);
8572            assert_eq!(record["work_id"], "wrk_0a");
8573            assert_eq!(record["status"], "ok");
8574            assert_eq!(record["duration_ms"], 3_000);
8575            assert_eq!(record["session_id"], "test-session-id");
8576            assert_eq!(record["parent_id"].as_str(), Some(w.session_event_id()));
8577        }
8578    }
8579
8580    #[tokio::test]
8581    async fn task_reopened_names_hook_reason_and_ordinal() {
8582        let dir = tempfile::tempdir().unwrap();
8583        let mut w = make_writer(dir.path()).await;
8584        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8585            .await
8586            .unwrap();
8587        w.write_task_reopened(
8588            "tsk_1",
8589            "gatekeeper",
8590            "tests still fail",
8591            1,
8592            REOPEN_CONTEXT_CONTINUED,
8593            Some(7),
8594        )
8595        .await
8596        .unwrap();
8597        w.flush().await.unwrap();
8598
8599        let events = read_events(dir.path());
8600        let re = events
8601            .iter()
8602            .find(|e| e["event_type"] == "task_reopened")
8603            .unwrap();
8604        assert_eq!(re["task_id"], "tsk_1");
8605        assert_eq!(re["hook_name"], "gatekeeper");
8606        assert_eq!(re["reason"], "tests still fail");
8607        assert_eq!(re["reopen_number"], 1);
8608        assert_eq!(re["attempt_context"], "continued");
8609        assert_eq!(re["turns_remaining"], 7);
8610
8611        w.write_task_reopened(
8612            "tsk_1",
8613            "gatekeeper",
8614            "again",
8615            2,
8616            REOPEN_CONTEXT_CONTINUED,
8617            None,
8618        )
8619        .await
8620        .unwrap();
8621        w.flush().await.unwrap();
8622        let uncapped = read_events(dir.path())
8623            .into_iter()
8624            .filter(|e| e["event_type"] == "task_reopened")
8625            .nth(1)
8626            .unwrap();
8627        assert!(uncapped
8628            .get("turns_remaining")
8629            .is_some_and(serde_json::Value::is_null));
8630        assert!(re["timestamp"].as_u64().unwrap() > 0);
8631    }
8632
8633    #[tokio::test]
8634    async fn task_continued_names_the_round_under_the_task_node() {
8635        let dir = tempfile::tempdir().unwrap();
8636        let mut w = make_writer(dir.path()).await;
8637        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8638            .await
8639            .unwrap();
8640        w.write_task_continued(
8641            "tsk_1",
8642            1,
8643            &["mcl_b".to_string(), "mcl_a".to_string()],
8644            &["dlg_1".to_string()],
8645            1234,
8646            Some(8),
8647        )
8648        .await
8649        .unwrap();
8650        w.flush().await.unwrap();
8651
8652        let events = read_events(dir.path());
8653        let start = events
8654            .iter()
8655            .find(|e| e["event_type"] == "task_start")
8656            .unwrap();
8657        let continued = events
8658            .iter()
8659            .find(|e| e["event_type"] == "task_continued")
8660            .unwrap();
8661        assert_eq!(continued["parent_id"], start["event_id"]);
8662        assert_eq!(continued["task_id"], "tsk_1");
8663        assert_eq!(continued["continuation_number"], 1);
8664        assert_eq!(
8665            continued["member_calls"],
8666            serde_json::json!(["mcl_b", "mcl_a"])
8667        );
8668        assert_eq!(continued["delegations"], serde_json::json!(["dlg_1"]));
8669        assert_eq!(continued["waited_ms"], 1234);
8670        assert_eq!(continued["turns_remaining"], 8);
8671    }
8672
8673    /// `task_turns()` reports the cumulative per-task turn count the reopen loop reads to
8674    /// compute the remaining `max_turns` budget: it advances with each `write_inference`
8675    /// and resets on `write_task_start`.
8676    #[tokio::test]
8677    async fn task_turns_accessor_tracks_and_resets() {
8678        let dir = tempfile::tempdir().unwrap();
8679        let mut w = make_writer(dir.path()).await;
8680        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8681            .await
8682            .unwrap();
8683        assert_eq!(w.task_turns(), 0);
8684        w.write_inference(
8685            0,
8686            Some(10),
8687            Some(5),
8688            "end_turn".to_string(),
8689            Some("end_turn"),
8690            None,
8691            None,
8692            None,
8693            Vec::new(),
8694            None,
8695            None,
8696        )
8697        .await
8698        .unwrap();
8699        w.write_inference(
8700            1,
8701            Some(10),
8702            Some(5),
8703            "end_turn".to_string(),
8704            Some("end_turn"),
8705            None,
8706            None,
8707            None,
8708            Vec::new(),
8709            None,
8710            None,
8711        )
8712        .await
8713        .unwrap();
8714        assert_eq!(w.task_turns(), 2);
8715        // A new task resets the per-task counter.
8716        w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), 3, &[], &[])
8717            .await
8718            .unwrap();
8719        assert_eq!(w.task_turns(), 0);
8720    }
8721
8722    /// `task_agent_turns()` counts the agent loop's own `inference` lines and not a hook's
8723    /// `run-inference` record, which `task_turns()` does count; both task boundaries reset it.
8724    #[tokio::test]
8725    async fn task_agent_turns_counts_agent_loop_inference_only() {
8726        let dir = tempfile::tempdir().unwrap();
8727        let mut w = make_writer(dir.path()).await;
8728        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8729            .await
8730            .unwrap();
8731        assert_eq!(w.task_agent_turns(), 0);
8732        let hook = InferenceOrigin {
8733            source: "hook:x".into(),
8734            model: "m".into(),
8735        };
8736        for (turn, origin) in [(0, None), (0, Some(&hook)), (1, None)] {
8737            w.write_inference(
8738                turn,
8739                Some(10),
8740                Some(5),
8741                "end_turn".to_string(),
8742                Some("end_turn"),
8743                None,
8744                origin,
8745                None,
8746                Vec::new(),
8747                None,
8748                None,
8749            )
8750            .await
8751            .unwrap();
8752        }
8753        assert_eq!(w.task_agent_turns(), 2);
8754        assert_eq!(w.task_turns(), 3);
8755        assert_eq!(w.task_hook_inferences(), 1);
8756        w.write_task_end("tsk_1", "completed", 0, Vec::new(), None, None)
8757            .await
8758            .unwrap();
8759        assert_eq!(
8760            w.task_agent_turns(),
8761            0,
8762            "a run outside a task frame starts at 0"
8763        );
8764        w.write_inference(
8765            0,
8766            Some(10),
8767            Some(5),
8768            "end_turn".to_string(),
8769            Some("end_turn"),
8770            None,
8771            None,
8772            None,
8773            Vec::new(),
8774            None,
8775            None,
8776        )
8777        .await
8778        .unwrap();
8779        w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), 3, &[], &[])
8780            .await
8781            .unwrap();
8782        assert_eq!(w.task_agent_turns(), 0);
8783    }
8784
8785    // ── Event identity and parenting ──────────────────────────────────────────
8786
8787    /// The full tree one task produces, walked the way a reader walks it: every line is
8788    /// identified, every non-null `parent_id` names a line already written, and the shape is
8789    /// session → task → turn → {tool_call, shell}.
8790    #[tokio::test]
8791    async fn every_event_carries_a_unique_id_and_parents_to_an_earlier_event() {
8792        let dir = tempfile::tempdir().unwrap();
8793        let mut w = make_writer(dir.path()).await;
8794        w.write_session_start(Some(10), None, vec!["bash".to_string()])
8795            .await
8796            .unwrap();
8797        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8798            .await
8799            .unwrap();
8800        w.write_inference(
8801            0,
8802            Some(10),
8803            Some(5),
8804            "tool_call".to_string(),
8805            Some("tool_call"),
8806            None,
8807            None,
8808            None,
8809            Vec::new(),
8810            None,
8811            None,
8812        )
8813        .await
8814        .unwrap();
8815        w.write_tool_call(
8816            0,
8817            "bash".to_string(),
8818            Some("toolu_1".to_string()),
8819            serde_json::json!({"command": "echo hi"}),
8820            10,
8821            "hi\n",
8822            3,
8823            5,
8824            "ok".to_string(),
8825            None,
8826            None,
8827            None,
8828        )
8829        .await
8830        .unwrap();
8831        w.write_shell(
8832            0,
8833            "/bin/bash".to_string(),
8834            "echo hi".to_string(),
8835            0,
8836            3,
8837            0,
8838            2,
8839            None,
8840        )
8841        .await
8842        .unwrap();
8843        w.write_artifact_pulled(&pulled(
8844            runtime_summary("pulled-style", ArtifactRuntime::Skill),
8845            "global store",
8846            None,
8847        ))
8848        .await
8849        .unwrap();
8850        w.write_task_end("tsk_1", "ok", 0, Vec::new(), None, None)
8851            .await
8852            .unwrap();
8853        w.write_session_end("ok").await.unwrap();
8854        w.flush().await.unwrap();
8855
8856        let events = read_events(dir.path());
8857        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
8858        for (i, event) in events.iter().enumerate() {
8859            let id = event["event_id"].as_str().unwrap();
8860            assert!(id.starts_with("evt_") && id.len() == 36, "event {i}: {id}");
8861            assert!(id[4..].chars().all(|c| c.is_ascii_hexdigit()), "event {i}");
8862            assert!(seen.insert(id.to_string()), "event {i} reuses {id}");
8863            assert!(
8864                event.get("parent_id").is_some(),
8865                "event {i} has no parent_id"
8866            );
8867            if let Some(parent) = event["parent_id"].as_str() {
8868                assert!(seen.contains(parent), "event {i} names dangling {parent}");
8869            }
8870        }
8871
8872        let by_type = |ty: &str| {
8873            events
8874                .iter()
8875                .find(|e| e["event_type"] == ty)
8876                .unwrap_or_else(|| panic!("no {ty}"))
8877                .clone()
8878        };
8879        let session = by_type("session_start");
8880        assert!(session["parent_id"].is_null());
8881        assert_eq!(session["event_id"], w.session_event_id());
8882
8883        let task = by_type("task_start");
8884        assert_eq!(task["parent_id"], session["event_id"]);
8885
8886        let turn = by_type("inference");
8887        assert_eq!(turn["parent_id"], task["event_id"]);
8888
8889        assert_eq!(by_type("tool_call")["parent_id"], turn["event_id"]);
8890        assert_eq!(by_type("shell")["parent_id"], turn["event_id"]);
8891        assert_eq!(by_type("task_end")["parent_id"], task["event_id"]);
8892        assert_eq!(by_type("session_end")["parent_id"], session["event_id"]);
8893        assert_eq!(by_type("artifact_pulled")["parent_id"], session["event_id"]);
8894    }
8895
8896    fn runtime_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
8897        InstalledArtifactSummary {
8898            name: name.to_string(),
8899            version: "1.0.0".to_string(),
8900            runtime,
8901            implementation: None,
8902            origin: LockOrigin::Runtime {
8903                session: "ses_puller".to_string(),
8904            },
8905        }
8906    }
8907
8908    fn operator_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
8909        InstalledArtifactSummary {
8910            origin: LockOrigin::Operator,
8911            ..runtime_summary(name, runtime)
8912        }
8913    }
8914
8915    /// A pull of `summary` from `fetched_from`, with `owner_id` when the place records one.
8916    fn pulled(
8917        summary: InstalledArtifactSummary,
8918        fetched_from: &str,
8919        owner_id: Option<u64>,
8920    ) -> crate::runtime::PulledArtifact {
8921        crate::runtime::PulledArtifact {
8922            summary,
8923            fetched_from: fetched_from.to_string(),
8924            owner_id,
8925            notes: Vec::new(),
8926        }
8927    }
8928
8929    /// `instruction_files` is always written: `null` without the key, the records in manifest
8930    /// order with it, and `[]` for an empty list.
8931    #[tokio::test]
8932    async fn session_start_instruction_files_is_null_without_the_key() {
8933        let work = tempfile::tempdir().unwrap();
8934        std::fs::write(work.path().join("AGENTS.md"), "x").unwrap();
8935        let read = crate::instruction_files::InstructionFiles::read(
8936            work.path(),
8937            &["AGENTS.md".to_string(), "CLAUDE.md".to_string()],
8938        );
8939        let empty = crate::instruction_files::InstructionFiles::read(work.path(), &[]);
8940        for (files, expected) in [
8941            (None, serde_json::Value::Null),
8942            (Some(&empty), serde_json::json!([])),
8943            (
8944                Some(&read),
8945                serde_json::json!([
8946                    {
8947                        "path": "AGENTS.md",
8948                        "status": "read",
8949                        "resolved": "AGENTS.md",
8950                        "bytes": 1,
8951                        "included_bytes": 1,
8952                        "truncated": false,
8953                        "sha256": murmur_artifact::sha256_hex(b"x"),
8954                    },
8955                    {"path": "CLAUDE.md", "status": "missing"},
8956                ]),
8957            ),
8958        ] {
8959            let dir = tempfile::tempdir().unwrap();
8960            let mut w = make_writer(dir.path()).await;
8961            w.set_instruction_files(files);
8962            w.write_session_start(Some(10), None, Vec::new())
8963                .await
8964                .unwrap();
8965            w.flush().await.unwrap();
8966            let events = read_events(dir.path());
8967            assert!(events[0].get("instruction_files").is_some());
8968            assert_eq!(events[0]["instruction_files"], expected);
8969        }
8970    }
8971
8972    fn harness_start_with_prompt(prompt: &str) -> HarnessStart {
8973        HarnessStart {
8974            driver: "local/fixture-process-driver".to_string(),
8975            driver_version: "0.1.0".to_string(),
8976            harness: "fixture".to_string(),
8977            binary: "/bin/true".to_string(),
8978            binary_source: "describe".to_string(),
8979            harness_version: None,
8980            version_tested: false,
8981            harness_session_id: "h".to_string(),
8982            session_mode: "new".to_string(),
8983            args_count: 0,
8984            env_names: Vec::new(),
8985            account_env_unset: Vec::new(),
8986            files: Vec::new(),
8987            bridge_tools: Vec::new(),
8988            stdin_bytes: 0,
8989            keep_stdin_open: false,
8990            system_prompt_sha256: murmur_artifact::sha256_hex(prompt.as_bytes()),
8991            system_prompt: prompt.to_string(),
8992        }
8993    }
8994
8995    /// `harness_start` names the prompt handed to the driver by its hash, never carries the text,
8996    /// and under content capture stores the text as that blob.
8997    #[tokio::test]
8998    async fn harness_start_records_the_system_prompt_hash_and_blob() {
8999        let prompt = "facts\n\nMANIFEST\n\n[Instruction file: AGENTS.md]\nx";
9000        let sha = murmur_artifact::sha256_hex(prompt.as_bytes());
9001        for capture in [TraceCapture::Meta, TraceCapture::Content] {
9002            let dir = tempfile::tempdir().unwrap();
9003            let mut w = make_writer_with_opts(dir.path(), capture).await;
9004            w.write_harness_start(harness_start_with_prompt(prompt))
9005                .await
9006                .unwrap();
9007            w.flush().await.unwrap();
9008            let event = &read_events(dir.path())[0];
9009            assert_eq!(event["event_type"], "harness_start");
9010            assert_eq!(event["system_prompt_sha256"], sha.as_str());
9011            assert!(event.get("system_prompt").is_none(), "{event}");
9012            let blob = dir.path().join(BLOB_DIR_NAME).join(&sha);
9013            if capture == TraceCapture::Content {
9014                assert_eq!(std::fs::read(&blob).unwrap(), prompt.as_bytes());
9015            } else {
9016                assert!(!blob.exists());
9017            }
9018        }
9019    }
9020
9021    /// `runtime_artifacts` is always written: `[]` for a session whose every artifact the operator
9022    /// pinned, including one that never called the setter.
9023    #[tokio::test]
9024    async fn runtime_origin_session_start_lists_nothing_for_an_operator_session() {
9025        for set in [false, true] {
9026            let dir = tempfile::tempdir().unwrap();
9027            let mut w = make_writer(dir.path()).await;
9028            if set {
9029                w.set_runtime_artifacts(&[
9030                    operator_summary("house-style", ArtifactRuntime::Skill),
9031                    operator_summary("fetcher", ArtifactRuntime::Tool),
9032                ]);
9033            }
9034            w.write_session_start(Some(10), None, Vec::new())
9035                .await
9036                .unwrap();
9037            w.flush().await.unwrap();
9038            let events = read_events(dir.path());
9039            assert_eq!(events[0]["runtime_artifacts"], serde_json::json!([]));
9040        }
9041    }
9042
9043    /// Only runtime-origin entries are listed, in the order staging recorded them, each naming
9044    /// its puller.
9045    #[tokio::test]
9046    async fn runtime_origin_session_start_lists_runtime_artifacts_in_order() {
9047        let dir = tempfile::tempdir().unwrap();
9048        let mut w = make_writer(dir.path()).await;
9049        w.set_runtime_artifacts(&[
9050            runtime_summary("zeta-tool", ArtifactRuntime::Tool),
9051            operator_summary("house-style", ArtifactRuntime::Skill),
9052            InstalledArtifactSummary {
9053                origin: LockOrigin::Runtime {
9054                    session: "ses_other".to_string(),
9055                },
9056                ..runtime_summary("alpha-style", ArtifactRuntime::Skill)
9057            },
9058        ]);
9059        w.write_session_start(Some(10), None, Vec::new())
9060            .await
9061            .unwrap();
9062        w.flush().await.unwrap();
9063
9064        let events = read_events(dir.path());
9065        assert_eq!(
9066            events[0]["runtime_artifacts"],
9067            serde_json::json!([
9068                {"name": "zeta-tool", "version": "1.0.0", "origin": "runtime",
9069                 "session": "ses_puller", "trust": "untrusted"},
9070                {"name": "alpha-style", "version": "1.0.0", "origin": "runtime",
9071                 "session": "ses_other", "trust": "untrusted"},
9072            ])
9073        );
9074    }
9075
9076    #[tokio::test]
9077    async fn runtime_origin_skill_call_carries_origin_and_trust() {
9078        let dir = tempfile::tempdir().unwrap();
9079        let mut w = make_writer(dir.path()).await;
9080        for origin in [
9081            LockOrigin::Operator,
9082            LockOrigin::Runtime {
9083                session: "ses_puller".to_string(),
9084            },
9085        ] {
9086            w.write_skill_call(0, "a-skill".to_string(), &origin, 1, 1, "ok".to_string())
9087                .await
9088                .unwrap();
9089        }
9090        w.flush().await.unwrap();
9091
9092        let events = read_events(dir.path());
9093        assert_eq!(events[0]["origin"], "operator");
9094        assert_eq!(events[0]["trust"], "trusted");
9095        assert_eq!(events[1]["origin"], "runtime");
9096        assert_eq!(events[1]["trust"], "untrusted");
9097    }
9098
9099    /// Every key is written, `session` included: `null` when the lock entry is the operator's.
9100    /// `owner_id` is written only when the place the payload came from records one.
9101    #[tokio::test]
9102    async fn runtime_origin_artifact_pulled_writes_every_key() {
9103        let dir = tempfile::tempdir().unwrap();
9104        let mut w = make_writer(dir.path()).await;
9105        w.write_artifact_pulled(&pulled(
9106            runtime_summary("pulled-style", ArtifactRuntime::Skill),
9107            "github:ana/artifacts",
9108            Some(4242),
9109        ))
9110        .await
9111        .unwrap();
9112        w.write_artifact_pulled(&pulled(
9113            operator_summary("fetcher", ArtifactRuntime::Tool),
9114            "project store",
9115            None,
9116        ))
9117        .await
9118        .unwrap();
9119        w.flush().await.unwrap();
9120
9121        let events = read_events(dir.path());
9122        let runtime = &events[0];
9123        assert_eq!(runtime["event_type"], "artifact_pulled");
9124        assert_eq!(runtime["name"], "pulled-style");
9125        assert_eq!(runtime["version"], "1.0.0");
9126        assert_eq!(runtime["runtime"], "skill");
9127        assert_eq!(runtime["origin"], "runtime");
9128        assert_eq!(runtime["session"], "ses_puller");
9129        assert_eq!(runtime["trust"], "untrusted");
9130        assert_eq!(runtime["fetched_from"], "github:ana/artifacts");
9131        assert_eq!(runtime["owner_id"], 4242);
9132        assert!(
9133            runtime["parent_id"].is_null(),
9134            "no session_start was written"
9135        );
9136
9137        let operator = &events[1];
9138        assert_eq!(operator["runtime"], "tool");
9139        assert_eq!(operator["origin"], "operator");
9140        assert!(operator.get("session").is_some_and(Value::is_null));
9141        assert_eq!(operator["trust"], "trusted");
9142        assert_eq!(operator["fetched_from"], "project store");
9143        assert!(operator.get("owner_id").is_none());
9144    }
9145
9146    /// A hook's `run-inference` record is a product of the turn it ran inside, not a turn of
9147    /// its own: it parents to the agent loop's inference and never replaces it as the node the
9148    /// turn's other events hang off.
9149    #[tokio::test]
9150    async fn hook_origin_inference_hangs_off_the_turn_rather_than_becoming_one() {
9151        let dir = tempfile::tempdir().unwrap();
9152        let mut w = make_writer(dir.path()).await;
9153        w.write_session_start(Some(10), None, Vec::new())
9154            .await
9155            .unwrap();
9156        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9157            .await
9158            .unwrap();
9159        w.write_inference(
9160            0,
9161            Some(10),
9162            Some(5),
9163            "tool_call".to_string(),
9164            Some("tool_call"),
9165            None,
9166            None,
9167            None,
9168            Vec::new(),
9169            None,
9170            None,
9171        )
9172        .await
9173        .unwrap();
9174        let origin = InferenceOrigin {
9175            source: "hook:gatekeeper".to_string(),
9176            model: "claude-test".to_string(),
9177        };
9178        w.write_inference(
9179            0,
9180            Some(1),
9181            Some(1),
9182            "end_turn".to_string(),
9183            // A hook's `run-inference` saw no provider stop reason, so it records none.
9184            None,
9185            None,
9186            Some(&origin),
9187            None,
9188            Vec::new(),
9189            None,
9190            None,
9191        )
9192        .await
9193        .unwrap();
9194        w.flush().await.unwrap();
9195
9196        let events = read_events(dir.path());
9197        let turn = &events[2];
9198        let hook = &events[3];
9199        assert!(turn["origin"].is_null());
9200        assert_eq!(hook["origin"], "hook:gatekeeper");
9201        assert_eq!(hook["parent_id"], turn["event_id"]);
9202        // The hook sent a message list the runtime never minted, so the key is absent rather
9203        // than empty.
9204        assert!(hook.get("message_ids").is_none(), "{hook}");
9205    }
9206
9207    /// A writer with no session frame behind it names no parent: naming one would dangle.
9208    #[tokio::test]
9209    async fn a2a_send_parents_to_null_without_a_session_frame_and_to_it_with_one() {
9210        let bare = tempfile::tempdir().unwrap();
9211        let mut w = make_writer(bare.path()).await;
9212        w.write_a2a_send(
9213            "http://peer",
9214            "msg_1",
9215            "tsk_1",
9216            "ctx_1",
9217            None,
9218            TrustClass::Untrusted,
9219        )
9220        .await
9221        .unwrap();
9222        w.flush().await.unwrap();
9223        assert!(read_events(bare.path())[0]["parent_id"].is_null());
9224
9225        let framed = tempfile::tempdir().unwrap();
9226        let mut w = make_writer(framed.path()).await;
9227        w.write_session_start(Some(1), None, Vec::new())
9228            .await
9229            .unwrap();
9230        w.write_a2a_send(
9231            "http://peer",
9232            "msg_1",
9233            "tsk_1",
9234            "ctx_1",
9235            None,
9236            TrustClass::Untrusted,
9237        )
9238        .await
9239        .unwrap();
9240        w.flush().await.unwrap();
9241        let events = read_events(framed.path());
9242        assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
9243    }
9244
9245    /// `task_rejected` carries every field and, with no task active, hangs off `session_start`.
9246    #[tokio::test]
9247    async fn task_rejected_names_the_task_source_cause_and_reason_under_the_session() {
9248        let dir = tempfile::tempdir().unwrap();
9249        let mut w = make_writer(dir.path()).await;
9250        w.write_session_start(Some(10), None, Vec::new())
9251            .await
9252            .unwrap();
9253        w.write_task_start("tsk_ran", "ctx_ran", "a2a", event_provenance(), 3, &[], &[])
9254            .await
9255            .unwrap();
9256        w.write_task_end("tsk_ran", "ok", 0, Vec::new(), None, None)
9257            .await
9258            .unwrap();
9259        w.write_task_rejected(
9260            "tsk_1",
9261            "ctx_1",
9262            "detached_shell",
9263            TASK_REJECTED_SESSION_ENDED,
9264            "task rejected: the session ended before this task started",
9265        )
9266        .await
9267        .unwrap();
9268        w.flush().await.unwrap();
9269
9270        let events = read_events(dir.path());
9271        let session = events
9272            .iter()
9273            .find(|e| e["event_type"] == "session_start")
9274            .unwrap();
9275        let rejected: Vec<_> = events
9276            .iter()
9277            .filter(|e| e["event_type"] == "task_rejected")
9278            .collect();
9279        assert_eq!(rejected.len(), 1);
9280        let rejected = rejected[0];
9281        assert_eq!(rejected["parent_id"], session["event_id"], "{rejected}");
9282        assert_eq!(rejected["session_id"], session["session_id"]);
9283        assert_eq!(rejected["task_id"], "tsk_1");
9284        assert_eq!(rejected["context_id"], "ctx_1");
9285        assert_eq!(rejected["source"], "detached_shell");
9286        assert_eq!(rejected["cause"], "session_ended");
9287        assert_eq!(
9288            rejected["reason"],
9289            "task rejected: the session ended before this task started"
9290        );
9291        assert!(rejected["event_id"].is_string());
9292        assert!(rejected["timestamp"].is_u64());
9293        assert_eq!(rejected.as_object().unwrap().len(), 10, "{rejected}");
9294    }
9295
9296    /// `task_failed` hangs off the task it failed, and a reason past the cap is cut on a character
9297    /// boundary. Both optional fields are absent, not null, when there is nothing to say.
9298    #[tokio::test]
9299    async fn task_failed_names_the_task_turn_cause_and_capped_reason() {
9300        let dir = tempfile::tempdir().unwrap();
9301        let mut w = make_writer(dir.path()).await;
9302        w.write_session_start(Some(10), None, Vec::new())
9303            .await
9304            .unwrap();
9305        assert_eq!(w.last_task_failure(), None);
9306        w.write_task_failed(None, TASK_FAILED_RUNTIME_ERROR, "before any task")
9307            .await
9308            .unwrap();
9309        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9310            .await
9311            .unwrap();
9312        let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
9313        w.write_task_failed(Some(2), TASK_FAILED_DRIVER_ERROR, &long)
9314            .await
9315            .unwrap();
9316        w.write_task_end("tsk_1", "failed", 0, Vec::new(), None, None)
9317            .await
9318            .unwrap();
9319        w.flush().await.unwrap();
9320
9321        assert_eq!(w.task_failures_written(), 2);
9322        let events = read_events(dir.path());
9323        let failed: Vec<_> = events
9324            .iter()
9325            .filter(|e| e["event_type"] == "task_failed")
9326            .collect();
9327        assert_eq!(failed.len(), 2);
9328
9329        assert!(failed[0].get("task_id").is_none(), "{}", failed[0]);
9330        assert!(failed[0].get("turn").is_none(), "{}", failed[0]);
9331        assert_eq!(failed[0]["cause"], "runtime_error");
9332        assert_eq!(failed[0]["reason"], "before any task");
9333
9334        let start = events
9335            .iter()
9336            .find(|e| e["event_type"] == "task_start")
9337            .unwrap();
9338        assert_eq!(failed[1]["task_id"], "tsk_1");
9339        assert_eq!(failed[1]["turn"], 2);
9340        assert_eq!(failed[1]["cause"], "driver_error");
9341        assert_eq!(failed[1]["parent_id"], start["event_id"]);
9342        assert_eq!(failed[1]["session_id"], start["session_id"]);
9343        assert!(failed[1]["event_id"].is_string());
9344        assert!(failed[1]["timestamp"].is_u64());
9345        let reason = failed[1]["reason"].as_str().unwrap();
9346        assert!(reason.len() <= MAX_TASK_FAILURE_REASON_BYTES);
9347        assert!(reason.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
9348        assert_eq!(w.last_task_failure(), Some(reason));
9349    }
9350
9351    /// Turn-level events carry the enclosing task's id, and `null` once the task has ended —
9352    /// `task_id` is a record of scope, not a value the writer holds on to.
9353    #[tokio::test]
9354    async fn turn_level_events_carry_the_active_task_id() {
9355        let dir = tempfile::tempdir().unwrap();
9356        let mut w = make_writer(dir.path()).await;
9357        w.write_session_start(Some(10), None, Vec::new())
9358            .await
9359            .unwrap();
9360        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9361            .await
9362            .unwrap();
9363        w.write_inference(
9364            0,
9365            Some(10),
9366            Some(5),
9367            "tool_call".to_string(),
9368            Some("tool_call"),
9369            None,
9370            None,
9371            None,
9372            Vec::new(),
9373            None,
9374            None,
9375        )
9376        .await
9377        .unwrap();
9378        w.write_skill_call(
9379            0,
9380            "house-style".to_string(),
9381            &LockOrigin::Operator,
9382            12,
9383            1,
9384            "ok".to_string(),
9385        )
9386        .await
9387        .unwrap();
9388        w.write_compaction(0, 100, OccupancyBasis::Estimate, 40)
9389            .await
9390            .unwrap();
9391        w.write_task_end("tsk_1", "ok", 0, Vec::new(), None, None)
9392            .await
9393            .unwrap();
9394        w.write_inference(
9395            1,
9396            Some(1),
9397            Some(1),
9398            "end_turn".to_string(),
9399            Some("end_turn"),
9400            None,
9401            None,
9402            None,
9403            Vec::new(),
9404            None,
9405            None,
9406        )
9407        .await
9408        .unwrap();
9409        w.flush().await.unwrap();
9410
9411        let events = read_events(dir.path());
9412        for event in events
9413            .iter()
9414            .filter(|e| {
9415                matches!(
9416                    e["event_type"].as_str(),
9417                    Some("inference" | "skill_call" | "compaction")
9418                )
9419            })
9420            .take(3)
9421        {
9422            assert_eq!(event["task_id"], "tsk_1", "{event}");
9423        }
9424        let after = events.last().unwrap();
9425        assert_eq!(after["event_type"], "inference");
9426        assert!(
9427            after["task_id"].is_null(),
9428            "an inference outside any task carries a null task_id, not the last task's"
9429        );
9430        // Once the task has ended, its events fall back to the session node.
9431        assert_eq!(after["parent_id"], events[0]["event_id"]);
9432    }
9433
9434    /// The provider's id is recorded verbatim; an absent or empty one records as `null`, never
9435    /// as an id the provider never issued.
9436    #[tokio::test]
9437    async fn tool_call_records_the_provider_id_verbatim_and_empty_as_null() {
9438        for (given, expected) in [
9439            (Some("toolu_1".to_string()), Some("toolu_1")),
9440            (Some(String::new()), None),
9441            (None, None),
9442        ] {
9443            let dir = tempfile::tempdir().unwrap();
9444            let mut w = make_writer(dir.path()).await;
9445            w.write_tool_call(
9446                0,
9447                "bash".to_string(),
9448                given.clone(),
9449                serde_json::json!({}),
9450                2,
9451                "",
9452                0,
9453                1,
9454                "ok".to_string(),
9455                None,
9456                None,
9457                None,
9458            )
9459            .await
9460            .unwrap();
9461            w.flush().await.unwrap();
9462            let e = &read_events(dir.path())[0];
9463            match expected {
9464                Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
9465                None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
9466            }
9467        }
9468    }
9469
9470    /// `tool_input_refused` carries the tool, its call id (`null` when absent or empty), the
9471    /// missing names and the reason, and is written under every capture mode alike.
9472    #[tokio::test]
9473    async fn tool_input_refused_records_its_fields_in_every_capture_mode() {
9474        for capture in [
9475            TraceCapture::None,
9476            TraceCapture::Meta,
9477            TraceCapture::Content,
9478        ] {
9479            for (given, expected) in [
9480                (Some("toolu_1"), Some("toolu_1")),
9481                (Some(""), None),
9482                (None, None),
9483            ] {
9484                let dir = tempfile::tempdir().unwrap();
9485                let mut w = make_writer_with_opts(dir.path(), capture).await;
9486                w.write_tool_input_refused(
9487                    2,
9488                    "murmur-tool-editor",
9489                    given,
9490                    &["operation".to_string(), "dest_path".to_string()],
9491                    "murmur-tool-editor: missing required fields",
9492                )
9493                .await
9494                .unwrap();
9495                w.flush().await.unwrap();
9496                let e = &read_events(dir.path())[0];
9497                assert_eq!(e["event_type"], "tool_input_refused", "{capture:?}");
9498                assert!(e["event_id"].is_string());
9499                assert!(e["session_id"].is_string());
9500                assert!(e["timestamp"].is_u64());
9501                assert_eq!(e["turn"], 2);
9502                assert_eq!(e["tool_name"], "murmur-tool-editor");
9503                match expected {
9504                    Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
9505                    None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
9506                }
9507                assert_eq!(e["missing"], serde_json::json!(["operation", "dest_path"]));
9508                assert_eq!(e["reason"], "murmur-tool-editor: missing required fields");
9509            }
9510        }
9511    }
9512
9513    /// `tools_refreshed` carries the turn, the task, the trigger and three sorted name lists,
9514    /// hangs off the task node rather than the previous turn, and `session_start` records the
9515    /// declared trigger — `null` under `transport: process`.
9516    #[tokio::test]
9517    async fn tools_refreshed_event_records_trigger_names_and_task_parent() {
9518        let dir = tempfile::tempdir().unwrap();
9519        let mut w = make_writer(dir.path()).await;
9520        w.set_tool_refresh(Some("immediate"));
9521        w.write_session_start(Some(10), None, Vec::new())
9522            .await
9523            .unwrap();
9524        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9525            .await
9526            .unwrap();
9527        w.write_inference(
9528            0,
9529            Some(10),
9530            Some(5),
9531            "tool_call".to_string(),
9532            Some("tool_call"),
9533            None,
9534            None,
9535            None,
9536            Vec::new(),
9537            None,
9538            None,
9539        )
9540        .await
9541        .unwrap();
9542        w.write_tools_refreshed(
9543            1,
9544            "immediate",
9545            &["aaa-late-skill".to_string()],
9546            &[],
9547            &[
9548                "aaa-late-skill".to_string(),
9549                "zzz-existing-tool".to_string(),
9550            ],
9551        )
9552        .await
9553        .unwrap();
9554        w.flush().await.unwrap();
9555
9556        let events = read_events(dir.path());
9557        assert_eq!(events[0]["tool_refresh"], "immediate");
9558        let task = events
9559            .iter()
9560            .find(|e| e["event_type"] == "task_start")
9561            .unwrap();
9562        let refreshed: Vec<&Value> = events
9563            .iter()
9564            .filter(|e| e["event_type"] == "tools_refreshed")
9565            .collect();
9566        assert_eq!(refreshed.len(), 1);
9567        let r = refreshed[0];
9568        assert!(r["event_id"].as_str().unwrap().starts_with("evt_"));
9569        assert_eq!(r["parent_id"], task["event_id"]);
9570        assert_eq!(r["session_id"], "test-session-id");
9571        assert!(r["timestamp"].as_u64().is_some());
9572        assert_eq!(r["turn"], 1);
9573        assert_eq!(r["task_id"], "tsk_1");
9574        assert_eq!(r["trigger"], "immediate");
9575        assert_eq!(r["added"], serde_json::json!(["aaa-late-skill"]));
9576        assert_eq!(r["removed"], serde_json::json!([]));
9577        assert_eq!(
9578            r["tools"],
9579            serde_json::json!(["aaa-late-skill", "zzz-existing-tool"])
9580        );
9581
9582        let process_dir = tempfile::tempdir().unwrap();
9583        let mut process = make_writer(process_dir.path()).await;
9584        process.set_tool_refresh(None);
9585        process
9586            .write_session_start(Some(10), None, Vec::new())
9587            .await
9588            .unwrap();
9589        process.flush().await.unwrap();
9590        let start = &read_events(process_dir.path())[0];
9591        assert!(start.as_object().unwrap().contains_key("tool_refresh"));
9592        assert!(start["tool_refresh"].is_null());
9593    }
9594
9595    /// A declined compaction is a full record of the decline, not a bare marker: it names the
9596    /// turn, the task, the occupancy the session went on running over, and the reason.
9597    #[tokio::test]
9598    async fn compaction_declined_records_turn_task_tokens_and_reason() {
9599        let dir = tempfile::tempdir().unwrap();
9600        let mut w = make_writer(dir.path()).await;
9601        w.write_session_start(Some(10), None, Vec::new())
9602            .await
9603            .unwrap();
9604        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9605            .await
9606            .unwrap();
9607        w.write_inference(
9608            2,
9609            Some(10),
9610            Some(5),
9611            "tool_call".to_string(),
9612            Some("tool_call"),
9613            None,
9614            None,
9615            None,
9616            Vec::new(),
9617            None,
9618            None,
9619        )
9620        .await
9621        .unwrap();
9622        w.write_compaction_declined(
9623            2,
9624            4321,
9625            OccupancyBasis::Actual,
9626            COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL,
9627        )
9628        .await
9629        .unwrap();
9630        w.flush().await.unwrap();
9631
9632        let events = read_events(dir.path());
9633        let declined: Vec<&Value> = events
9634            .iter()
9635            .filter(|e| e["event_type"] == "compaction_declined")
9636            .collect();
9637        assert_eq!(declined.len(), 1);
9638        let d = declined[0];
9639        assert_eq!(d["reason"], "unresolved_tool_call");
9640        assert_eq!(d["turn"], 2);
9641        assert_eq!(d["task_id"], "tsk_1");
9642        assert_eq!(d["tokens"], 4321);
9643        assert_eq!(d["tokens_source"], "actual");
9644        assert!(d["event_id"].as_str().unwrap().starts_with("evt_"));
9645        assert_eq!(
9646            d["parent_id"],
9647            events
9648                .iter()
9649                .find(|e| e["event_type"] == "inference")
9650                .unwrap()["event_id"]
9651        );
9652    }
9653
9654    /// `evt_` ids sort by mint time and carry their own millisecond timestamp, the same
9655    /// property `mur trace list --since` reads off a `ses_` id.
9656    #[tokio::test]
9657    async fn event_ids_sort_by_mint_time() {
9658        let dir = tempfile::tempdir().unwrap();
9659        let mut w = make_writer(dir.path()).await;
9660        for turn in 0..5 {
9661            w.write_inference(
9662                turn,
9663                Some(1),
9664                Some(1),
9665                "end_turn".to_string(),
9666                Some("end_turn"),
9667                None,
9668                None,
9669                None,
9670                Vec::new(),
9671                None,
9672                None,
9673            )
9674            .await
9675            .unwrap();
9676        }
9677        w.flush().await.unwrap();
9678
9679        let ids: Vec<String> = read_events(dir.path())
9680            .iter()
9681            .map(|e| e["event_id"].as_str().unwrap().to_string())
9682            .collect();
9683        let mut sorted = ids.clone();
9684        sorted.sort();
9685        assert_eq!(ids, sorted, "evt_ ids must sort into mint order");
9686    }
9687
9688    // ── Wire capture: hashes, blobs and the capture gate ─────────────────────
9689
9690    /// A payload in the shape `build_driver_payload` produces, with `n` messages.
9691    fn wire_payload(system: &str, messages: usize) -> Value {
9692        let messages: Vec<Value> = (0..messages)
9693            .map(|i| serde_json::json!({"role": "user", "content": format!("m{i}")}))
9694            .collect();
9695        serde_json::json!({
9696            "model": "claude-test",
9697            "max_tokens": 1024,
9698            "messages": messages,
9699            "tools": [{"name": "bash"}],
9700            "params": {},
9701            "system": system,
9702        })
9703    }
9704
9705    async fn write_turn(w: &mut TraceWriter, turn: u32, payload: &Value, response: &str) {
9706        let wire = WireCapture::from_driver_payload(payload, response);
9707        w.write_inference(
9708            turn,
9709            Some(10),
9710            Some(5),
9711            "end_turn".to_string(),
9712            Some("end_turn"),
9713            None,
9714            None,
9715            None,
9716            vec!["msg_0000000000000000000000000000000a".to_string()],
9717            Some(&wire),
9718            None,
9719        )
9720        .await
9721        .unwrap();
9722    }
9723
9724    fn blob_path(dir: &std::path::Path, sha: &str) -> std::path::PathBuf {
9725        dir.join(BLOB_DIR_NAME).join(sha)
9726    }
9727
9728    /// Every hash an `inference` event names under `content` is the filename of a real file whose
9729    /// own digest is that filename — the whole point of the store.
9730    #[tokio::test]
9731    async fn content_capture_names_blobs_that_exist_and_rehash_to_their_names() {
9732        let dir = tempfile::tempdir().unwrap();
9733        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
9734        let payload = wire_payload("You are a capsule.", 3);
9735        write_turn(&mut w, 0, &payload, r#"{"stop_reason":"end_turn"}"#).await;
9736        w.flush().await.unwrap();
9737
9738        let e = &read_events(dir.path())[0];
9739        let mut named: Vec<String> = ["system_sha", "tools_sha", "response_sha"]
9740            .iter()
9741            .map(|key| {
9742                e[*key]
9743                    .as_str()
9744                    .unwrap_or_else(|| panic!("{key} missing from {e}"))
9745                    .to_string()
9746            })
9747            .collect();
9748        let shas = e["message_shas"].as_array().expect("message_shas");
9749        assert_eq!(shas.len(), 3);
9750        named.extend(shas.iter().map(|s| s.as_str().unwrap().to_string()));
9751
9752        for sha in &named {
9753            let path = blob_path(dir.path(), sha);
9754            let bytes =
9755                std::fs::read(&path).unwrap_or_else(|err| panic!("blob {sha} must exist: {err}"));
9756            assert_eq!(murmur_artifact::sha256_hex(&bytes), *sha);
9757            // A bare sha256 names content; `evt_`/`msg_`/`ses_` name entities. Neither a prefix
9758            // nor an extension may creep onto a blob name.
9759            assert_eq!(sha.len(), 64, "{sha}");
9760            assert!(
9761                sha.chars()
9762                    .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
9763                "{sha}"
9764            );
9765        }
9766    }
9767
9768    /// The hashed system body is the payload's `system` string, as text — so the blob reads as a
9769    /// prompt rather than as a JSON-quoted one.
9770    #[tokio::test]
9771    async fn the_system_blob_holds_the_prompt_as_readable_text() {
9772        let dir = tempfile::tempdir().unwrap();
9773        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
9774        let payload = wire_payload("[Capsule] you are cap.\nBe terse.", 1);
9775        write_turn(&mut w, 0, &payload, "{}").await;
9776        w.flush().await.unwrap();
9777
9778        let sha = read_events(dir.path())[0]["system_sha"]
9779            .as_str()
9780            .unwrap()
9781            .to_string();
9782        assert_eq!(
9783            std::fs::read_to_string(blob_path(dir.path(), &sha)).unwrap(),
9784            "[Capsule] you are cap.\nBe terse."
9785        );
9786    }
9787
9788    /// `meta` is the default and hashes exactly what `content` hashes; the difference is only
9789    /// whether the bodies are on disk.
9790    #[tokio::test]
9791    async fn meta_writes_the_same_hashes_and_no_bodies() {
9792        let payload = wire_payload("You are a capsule.", 2);
9793        let response = r#"{"stop_reason":"end_turn"}"#;
9794
9795        let meta = tempfile::tempdir().unwrap();
9796        let mut w = make_writer_with_opts(meta.path(), TraceCapture::Meta).await;
9797        write_turn(&mut w, 0, &payload, response).await;
9798        w.flush().await.unwrap();
9799
9800        let content = tempfile::tempdir().unwrap();
9801        let mut w = make_writer_with_opts(content.path(), TraceCapture::Content).await;
9802        write_turn(&mut w, 0, &payload, response).await;
9803        w.flush().await.unwrap();
9804
9805        let meta_event = read_events(meta.path()).remove(0);
9806        let content_event = read_events(content.path()).remove(0);
9807        for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
9808            assert_eq!(meta_event[key], content_event[key], "{key}");
9809        }
9810        assert!(
9811            !meta.path().join(BLOB_DIR_NAME).exists(),
9812            "meta must not create the blob directory"
9813        );
9814        assert!(content.path().join(BLOB_DIR_NAME).is_dir());
9815    }
9816
9817    /// The default a writer gets when the manifest declares no `trace:` block.
9818    #[tokio::test]
9819    async fn the_default_capture_mode_is_meta() {
9820        assert_eq!(TraceCapture::default(), TraceCapture::Meta);
9821        let dir = tempfile::tempdir().unwrap();
9822        let mut w = make_writer_with_prompt(dir.path(), TraceCapture::default(), None, false).await;
9823        write_turn(&mut w, 0, &wire_payload("s", 1), "{}").await;
9824        w.flush().await.unwrap();
9825
9826        let e = &read_events(dir.path())[0];
9827        assert!(e.get("system_sha").is_some());
9828        assert!(!dir.path().join(BLOB_DIR_NAME).exists());
9829    }
9830
9831    /// Under `none` not one of the four hash keys is present, and every other field is.
9832    #[tokio::test]
9833    async fn none_omits_every_hash_and_leaves_the_rest_of_the_event_intact() {
9834        let payload = wire_payload("You are a capsule.", 2);
9835
9836        let off = tempfile::tempdir().unwrap();
9837        let mut w = make_writer_with_opts(off.path(), TraceCapture::None).await;
9838        write_turn(&mut w, 3, &payload, "{}").await;
9839        w.flush().await.unwrap();
9840
9841        let e = read_events(off.path()).remove(0);
9842        for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
9843            assert!(
9844                e.get(key).is_none(),
9845                "{key} must be absent under none, got {e}"
9846            );
9847        }
9848        assert!(!off.path().join(BLOB_DIR_NAME).exists());
9849        for key in [
9850            "event_type",
9851            "event_id",
9852            "parent_id",
9853            "session_id",
9854            "timestamp",
9855            "turn",
9856            "task_id",
9857            "input_tokens",
9858            "output_tokens",
9859            "decision",
9860            "tool_name",
9861            "message_ids",
9862        ] {
9863            assert!(
9864                e.get(key).is_some(),
9865                "{key} must survive under none, got {e}"
9866            );
9867        }
9868        assert_eq!(e["turn"], 3);
9869        assert_eq!(e["message_ids"].as_array().unwrap().len(), 1);
9870    }
9871
9872    /// A prompt that does not change across ten turns is one blob, written once and never
9873    /// rewritten — the file's bytes are replaced by hand after the first turn and must survive.
9874    #[tokio::test]
9875    async fn an_unchanged_system_prompt_is_stored_once() {
9876        let dir = tempfile::tempdir().unwrap();
9877        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
9878        let payload = wire_payload("You are a capsule.", 1);
9879        write_turn(&mut w, 0, &payload, "{}").await;
9880
9881        let sha = {
9882            w.flush().await.unwrap();
9883            read_events(dir.path())[0]["system_sha"]
9884                .as_str()
9885                .unwrap()
9886                .to_string()
9887        };
9888        let path = blob_path(dir.path(), &sha);
9889        std::fs::write(&path, b"sentinel").unwrap();
9890
9891        for turn in 1..10 {
9892            write_turn(&mut w, turn, &payload, "{}").await;
9893        }
9894        w.flush().await.unwrap();
9895
9896        let events = read_events(dir.path());
9897        assert_eq!(events.len(), 10);
9898        for e in &events {
9899            assert_eq!(e["system_sha"], sha.as_str());
9900        }
9901        assert_eq!(
9902            std::fs::read(&path).unwrap(),
9903            b"sentinel",
9904            "an existing blob is never rewritten"
9905        );
9906        let matching = std::fs::read_dir(dir.path().join(BLOB_DIR_NAME))
9907            .unwrap()
9908            .filter(|entry| entry.as_ref().unwrap().file_name() == sha.as_str())
9909            .count();
9910        assert_eq!(matching, 1, "exactly one file bears that name");
9911    }
9912
9913    /// A record written for a completion the runtime parsed no driver response for names no stop
9914    /// reason: the key is absent, never an empty string. Absence means "no driver response was
9915    /// parsed here", so a reader can tell it apart from a driver that reported nothing — which is
9916    /// recorded as `""`.
9917    #[tokio::test]
9918    async fn stop_reason_is_absent_when_no_driver_response_was_parsed() {
9919        let dir = tempfile::tempdir().unwrap();
9920        let mut w = make_writer(dir.path()).await;
9921        w.write_inference(
9922            0,
9923            Some(10),
9924            Some(5),
9925            "end_turn".to_string(),
9926            None,
9927            None,
9928            None,
9929            None,
9930            Vec::new(),
9931            None,
9932            None,
9933        )
9934        .await
9935        .unwrap();
9936        w.flush().await.unwrap();
9937
9938        let event = read_events(dir.path()).remove(0);
9939        assert!(
9940            event.get("stop_reason").is_none(),
9941            "stop_reason must be absent, got {event}"
9942        );
9943    }
9944
9945    /// A hook's `run-inference` sends a payload the runtime never built, so its record names no
9946    /// hashes — absent from the JSON, not empty strings.
9947    #[tokio::test]
9948    async fn a_hook_origin_record_carries_no_hashes() {
9949        let dir = tempfile::tempdir().unwrap();
9950        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
9951        let origin = InferenceOrigin {
9952            source: "hook:gatekeeper".to_string(),
9953            model: "claude-test".to_string(),
9954        };
9955        w.write_inference(
9956            0,
9957            Some(1),
9958            Some(1),
9959            "end_turn".to_string(),
9960            // A hook's `run-inference` saw no provider stop reason, so it records none.
9961            None,
9962            None,
9963            Some(&origin),
9964            None,
9965            Vec::new(),
9966            None,
9967            None,
9968        )
9969        .await
9970        .unwrap();
9971        w.flush().await.unwrap();
9972
9973        let e = read_events(dir.path()).remove(0);
9974        assert_eq!(e["origin"], "hook:gatekeeper");
9975        for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
9976            assert!(e.get(key).is_none(), "{key} must be absent, got {e}");
9977        }
9978        assert!(!dir.path().join(BLOB_DIR_NAME).exists());
9979    }
9980
9981    /// Two runs sharing a prefix agree on that prefix's `message_shas` and disagree from the
9982    /// changed message onwards — the divergence index is the first unequal position.
9983    #[tokio::test]
9984    async fn message_shas_locate_the_index_two_runs_diverge_at() {
9985        async fn shas_for(payload: &Value) -> Vec<String> {
9986            let dir = tempfile::tempdir().unwrap();
9987            let mut w = make_writer_with_opts(dir.path(), TraceCapture::Meta).await;
9988            write_turn(&mut w, 0, payload, "{}").await;
9989            w.flush().await.unwrap();
9990            read_events(dir.path())[0]["message_shas"]
9991                .as_array()
9992                .unwrap()
9993                .iter()
9994                .map(|s| s.as_str().unwrap().to_string())
9995                .collect()
9996        }
9997
9998        let first = wire_payload("You are a capsule.", 4);
9999        let mut second = first.clone();
10000        second["messages"][2]["content"] = serde_json::json!("diverged");
10001
10002        let a = shas_for(&first).await;
10003        let b = shas_for(&second).await;
10004        assert_eq!(a.len(), 4);
10005        let divergence = a.iter().zip(&b).position(|(x, y)| x != y);
10006        assert_eq!(divergence, Some(2));
10007        assert_eq!(a[3], b[3], "only the changed message changes its own sha");
10008    }
10009
10010    /// A message repeated inside one request hashes to one name and one file. Ids never repeat;
10011    /// shas repeat exactly when content does, and that is what makes them comparable.
10012    #[tokio::test]
10013    async fn identical_messages_share_one_sha_and_one_blob() {
10014        let dir = tempfile::tempdir().unwrap();
10015        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10016        let payload = serde_json::json!({
10017            "messages": [{"role": "user", "content": "same"}, {"role": "user", "content": "same"}],
10018            "tools": [],
10019            "system": "s",
10020        });
10021        write_turn(&mut w, 0, &payload, "{}").await;
10022        w.flush().await.unwrap();
10023
10024        let shas = read_events(dir.path())[0]["message_shas"]
10025            .as_array()
10026            .unwrap()
10027            .iter()
10028            .map(|s| s.as_str().unwrap().to_string())
10029            .collect::<Vec<_>>();
10030        assert_eq!(shas[0], shas[1]);
10031        assert!(blob_path(dir.path(), &shas[0]).is_file());
10032    }
10033
10034    /// Blobs are the bytes as sent, so a peer handle a tool call would have had redacted out of
10035    /// its summary stays in the body a `content` capture stores.
10036    #[tokio::test]
10037    async fn a_message_blob_is_the_body_verbatim() {
10038        let dir = tempfile::tempdir().unwrap();
10039        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10040        let message = serde_json::json!({"role": "user", "content": "hello"});
10041        let payload = serde_json::json!({
10042            "messages": [message.clone()],
10043            "tools": [],
10044            "system": "s",
10045        });
10046        write_turn(&mut w, 0, &payload, "{}").await;
10047        w.flush().await.unwrap();
10048
10049        let sha = read_events(dir.path())[0]["message_shas"][0]
10050            .as_str()
10051            .unwrap()
10052            .to_string();
10053        assert_eq!(
10054            std::fs::read(blob_path(dir.path(), &sha)).unwrap(),
10055            serde_json::to_vec(&message).unwrap()
10056        );
10057    }
10058
10059    /// A delegation record a plan's `capsule` step wrote and one `delegate-task` wrote are the
10060    /// same record.
10061    ///
10062    /// Identical key sets and identical values on everything but the two fields that are
10063    /// per-line by construction — the event id and the clock. That is what makes a trace
10064    /// unable to say which surface launched a child, and it holds because both appenders
10065    /// serialize the same struct rather than because this test watches them.
10066    #[tokio::test]
10067    async fn a_plan_written_delegation_record_matches_a_delegate_task_one() {
10068        let plan_dir = tempfile::tempdir().unwrap();
10069        let tool_dir = tempfile::tempdir().unwrap();
10070        let session = "ses_0000000000004000800000000000plan".to_string();
10071        let node = "evt_0000000000004000800000000000node".to_string();
10072
10073        let plan = PlanTraceAppender::open(
10074            &crate::workdir_fs::WorkdirRoot::open(plan_dir.path()).unwrap(),
10075            session.clone(),
10076            node.clone(),
10077        )
10078        .unwrap();
10079        let tool = ResourceTraceAppender::open(
10080            &crate::workdir_fs::WorkdirRoot::open(tool_dir.path()).unwrap(),
10081            session.clone(),
10082            node.clone(),
10083        )
10084        .await
10085        .unwrap();
10086
10087        let snapshot = crate::workdir_snapshot::WorkdirSnapshot {
10088            source: "/home/you/project".to_string(),
10089            files: 2,
10090            dirs: 0,
10091            symlinks: 0,
10092            repointed_symlinks: 0,
10093            bytes: 12,
10094            skipped_count: 0,
10095            skipped: Vec::new(),
10096        };
10097        plan.write_delegation_start(
10098            "dlg_abc123",
10099            "worker",
10100            "0.1.0",
10101            "ses_child",
10102            ".murmur/c",
10103            "/r/ses_child",
10104            Some(&snapshot),
10105        );
10106        tool.write_delegation_start(
10107            "dlg_abc123",
10108            "worker",
10109            "0.1.0",
10110            "ses_child",
10111            ".murmur/c",
10112            "/r/ses_child",
10113            Some(&snapshot),
10114        )
10115        .await;
10116        plan.write_delegation(
10117            "worker",
10118            "0.1.0",
10119            Some("dlg_abc123".to_string()),
10120            Some("ses_child".to_string()),
10121            42,
10122            "completed",
10123            None,
10124        );
10125        tool.write_delegation(
10126            "worker",
10127            "0.1.0",
10128            Some("dlg_abc123".to_string()),
10129            Some("ses_child".to_string()),
10130            42,
10131            "completed",
10132            None,
10133        )
10134        .await;
10135
10136        let read = |dir: &Path| -> Vec<serde_json::Map<String, Value>> {
10137            std::fs::read_to_string(dir.join("trace.jsonl"))
10138                .unwrap()
10139                .lines()
10140                .map(|line| serde_json::from_str::<serde_json::Map<_, _>>(line).unwrap())
10141                .collect()
10142        };
10143        let from_plan = read(plan_dir.path());
10144        let from_tool = read(tool_dir.path());
10145        assert_eq!(from_plan.len(), 2);
10146        assert_eq!(from_tool.len(), 2);
10147        assert_eq!(
10148            from_plan[0]["workdir_snapshot"],
10149            serde_json::to_value(&snapshot).unwrap()
10150        );
10151
10152        for (planned, called, event_type) in [
10153            (&from_plan[0], &from_tool[0], "delegation_start"),
10154            (&from_plan[1], &from_tool[1], "delegation"),
10155        ] {
10156            assert_eq!(planned["event_type"], event_type);
10157            assert_eq!(called["event_type"], event_type);
10158            assert_eq!(
10159                planned.keys().collect::<Vec<_>>(),
10160                called.keys().collect::<Vec<_>>(),
10161                "{planned:?} vs {called:?}"
10162            );
10163            assert_eq!(planned["parent_id"], node.as_str());
10164            assert_eq!(called["parent_id"], node.as_str());
10165            for (key, value) in planned {
10166                if key == "event_id" || key == "timestamp" {
10167                    continue;
10168                }
10169                assert_eq!(value, &called[key], "{key}: {planned:?} vs {called:?}");
10170            }
10171            for key in ["plan_id", "step_id", "surface", "kind"] {
10172                assert!(!planned.contains_key(key), "{key} is on {planned:?}");
10173                assert!(!called.contains_key(key), "{key} is on {called:?}");
10174            }
10175        }
10176    }
10177
10178    /// `spawner_ended` carries the lineage `session_start` does, on the same terms: both keys
10179    /// present for a delegated child, both absent — not null — for a session launched with a
10180    /// lifeline but no spawner handle.
10181    #[tokio::test]
10182    async fn spawner_ended_names_the_lineage_or_omits_it() {
10183        let dir = tempfile::tempdir().unwrap();
10184        let session = "ses_0000000000004000800000000child".to_string();
10185        let node = "evt_0000000000004000800000000000node".to_string();
10186        let appender = ResourceTraceAppender::open(
10187            &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
10188            session.clone(),
10189            node.clone(),
10190        )
10191        .await
10192        .unwrap();
10193
10194        appender
10195            .write_spawner_ended(Some("ses_parent"), Some("dlg_abc123"))
10196            .await;
10197        appender.write_spawner_ended(None, None).await;
10198
10199        let lines: Vec<serde_json::Map<String, Value>> =
10200            std::fs::read_to_string(dir.path().join("trace.jsonl"))
10201                .unwrap()
10202                .lines()
10203                .map(|line| serde_json::from_str(line).unwrap())
10204                .collect();
10205        assert_eq!(lines.len(), 2);
10206        let named = &lines[0];
10207        assert_eq!(named["event_type"], "spawner_ended");
10208        assert_eq!(named["parent_id"], node.as_str());
10209        assert_eq!(named["session_id"], session.as_str());
10210        assert_eq!(named["spawned_by"], "ses_parent");
10211        assert_eq!(named["delegation_id"], "dlg_abc123");
10212        assert_eq!(
10213            named.keys().collect::<Vec<_>>(),
10214            [
10215                "delegation_id",
10216                "event_id",
10217                "event_type",
10218                "parent_id",
10219                "session_id",
10220                "spawned_by",
10221                "timestamp"
10222            ]
10223        );
10224        let bare = &lines[1];
10225        assert_eq!(bare["event_type"], "spawner_ended");
10226        assert!(!bare.contains_key("spawned_by"), "{bare:?}");
10227        assert!(!bare.contains_key("delegation_id"), "{bare:?}");
10228    }
10229}