Skip to main content

capsule_runtime/
trace.rs

1use std::sync::Arc;
2use std::time::{Instant, SystemTime, UNIX_EPOCH};
3
4use murmur_artifact::{ContainmentClass, LockOrigin, TraceCapture};
5use serde::Serialize;
6use serde_json::Value;
7
8use crate::{
9    agent::{DriverUsage, OccupancyBasis},
10    containment::ScopeReport,
11    credential_gateway::GatewayOffsets,
12    formation::FormationId,
13    gateway_credential::CredentialChange,
14    inference_retry::{InferenceRetryRecord, RetryNote, RetryStop},
15    lanes::TaskLane,
16    origin::{TaskProvenance, TrustClass},
17    trace_blobs::BlobStore,
18    types::InstalledArtifactSummary,
19    workdir_fs::WorkdirRoot,
20};
21
22/// A session's trace file, directly under its session root.
23pub(crate) const TRACE_FILE_NAME: &str = "trace.jsonl";
24
25pub(crate) struct TraceWriter {
26    /// The file's chain, shared with every other appender of the same `trace.jsonl`.
27    chain: Arc<crate::record_seal::InlineChain>,
28    /// The records directory the trace is in, which its seal is written beside.
29    records_root: WorkdirRoot,
30    /// What the seal written after `session_end` covered, for the OTel session span.
31    sealed_head: Option<crate::record_seal::SealedHead>,
32    session_id: String,
33    capsule_name: String,
34    capsule_version: String,
35    model: String,
36    capabilities: Vec<String>,
37    /// The session's complete effective grant set, computed once at stage time. Written whole to
38    /// `session_start` as `effective_grants`, and also the source of that event's
39    /// `containment_declared`/`containment_achieved`/`workdir_exec` summary fields — they are read
40    /// off this report rather than passed in beside it, so a trace cannot claim one containment
41    /// class at the top level and another inside the report.
42    effective_grants: ScopeReport,
43    /// How much of each turn's driver request this trace keeps — see [`TraceCapture`]. Decides
44    /// whether an `inference` event carries content hashes at all, whether the bodies behind
45    /// them reach [`Self::blobs`], and whether `tool_call.output` is written.
46    capture: TraceCapture,
47    /// The content-addressed bodies this session stored, at `<workdir>/blobs/<sha256>`. Only
48    /// written to under [`TraceCapture::Content`]; the directory is created on the first blob.
49    blobs: BlobStore,
50    /// Where the effective system prompt came from: `"manifest"`, `"cli"` or `"none"`. Derived
51    /// once in [`TraceWriter::open`] from the resolved prompt and the override flag, then repeated
52    /// on every `session_start` the same way `model` is — the prompt cannot change between the
53    /// tasks of one session.
54    system_prompt_source: &'static str,
55    /// SHA-256 (lowercase hex) of the resolved system prompt, or `None` when no prompt is in
56    /// effect. Always written to `session_start` (as `null` when absent) so a trace records *that*
57    /// a prompt was in effect and which one, even when the text itself is withheld.
58    system_prompt_sha256: Option<String>,
59    /// Where the inference credential came from: `"config"`, `"environment"`, `"manifest"` or
60    /// `"none"`. Set by [`Self::set_credential_source`] before `session_start` is written.
61    credential_source: &'static str,
62    /// Every credential gateway the session holds. Set by [`Self::set_gateways`] before
63    /// `session_start` is written; empty until then.
64    gateways: Vec<SessionGateway>,
65    /// What a controller may change on this session. Set by [`Self::set_control`] before
66    /// `session_start` is written; `None` for a capsule with no `control:` block.
67    control: Option<SessionControl>,
68    /// The driver choices. Set by [`Self::set_inference_choices`] before `session_start` is
69    /// written; empty for a capsule with no alternates.
70    inference_choices: Vec<SessionInferenceChoice>,
71    /// `inference.tool_refresh` as `ToolRefresh::wire_name` spells it, or `None` under
72    /// `transport: process`. Set by [`Self::set_tool_refresh`] before `session_start` is written.
73    tool_refresh: Option<&'static str>,
74    /// The staged artifacts whose `murmur.lock` pin a running capsule fetched. Set by
75    /// [`Self::set_runtime_artifacts`] before `session_start` is written; empty until then.
76    runtime_artifacts: Vec<RuntimeArtifact>,
77    /// Where staging took the facts about each allowlisted binary it examined. Set by
78    /// [`Self::set_host_facts`] before `session_start` is written; empty until then.
79    host_facts: Vec<HostFactsRecord>,
80    /// What staging read for `inference.instruction_files`, one record per entry. Set by
81    /// [`Self::set_instruction_files`] before `session_start` is written; `None` until then and
82    /// for a capsule without the key.
83    instruction_files: Option<Vec<crate::instruction_files::InstructionFileRecord>>,
84    /// The session `mur run --resume` continued, verbatim as the operator's address resolved it.
85    /// `None` on every ordinary launch. Written to `session_start` on both, so its absence
86    /// identifies a trace from a runtime that predates the key.
87    resumed_from: Option<String>,
88    /// The launch-scoped context id: the `--context` value, or the id `--resume` resolved to.
89    /// `None` when each task of this launch mints its own.
90    context_id: Option<String>,
91    /// The session that spawned this one, and the delegation that created it, read out of the
92    /// handle the spawning capsule's launcher injected. Both `None` for a capsule nobody
93    /// delegated, and both omitted from `session_start` when absent rather than written as null.
94    spawned_by: Option<String>,
95    spawn_delegation_id: Option<String>,
96    /// The formation this session is a member of. Set by [`Self::set_formation_id`] before
97    /// `session_start` is written; `None` for a session in no formation.
98    formation_id: Option<FormationId>,
99    /// This session's roster name and callees, set by [`Self::set_formation_member`] for a
100    /// formation member.
101    formation_member: Option<(String, Vec<String>)>,
102    session_start_time: Instant,
103    /// The session node of the event tree: the `event_id` `session_start` carries, and the
104    /// `parent_id` every launch-scoped event names. Minted in [`TraceWriter::open`] rather than
105    /// at write time so [`ResourceTraceAppender`], which is opened before the frame is written,
106    /// can be handed the same value.
107    session_event_id: String,
108    session_started: bool,
109    session_ended: bool,
110    /// The task node: the `event_id` of the active task's `task_start`. Set by
111    /// [`Self::write_task_start`], cleared by [`Self::write_task_end`], `None` between tasks.
112    task_event_id: Option<String>,
113    /// The turn node: the `event_id` of the active turn's own agent-loop `inference`. Every
114    /// event a turn produces hangs off it. Cleared at both task boundaries so a new task never
115    /// parents its first events to the previous task's last turn.
116    turn_event_id: Option<String>,
117    // Running totals — updated as events are written, used for fallback session_end on error exit
118    total_turns: u32,
119    total_input_tokens: u64,
120    total_output_tokens: u64,
121    total_tool_calls: u32,
122    total_shell_calls: u32,
123    // Per-task counters — reset on each write_task_start; consumed by write_task_end
124    task_turns: u32,
125    task_input_tokens: u64,
126    task_output_tokens: u64,
127    task_tool_calls: u32,
128    task_shell_calls: u32,
129    /// Agent-loop `inference` lines (`origin` absent) written since `task_start`, counted or
130    /// not; a hook's `run-inference` record is not one. Where an attempt's turn numbering starts,
131    /// so a task's turns number straight through its reopens and continuations. Reset at both
132    /// task boundaries, so a run outside any task frame starts at 0.
133    task_agent_turns: u32,
134    /// Counted hook `run-inference` lines (`origin` present) written since `task_start`: the part
135    /// of [`Self::task_turns`] no agent-loop turn spent. Reset with the other per-task counters.
136    task_hook_inferences: u32,
137    /// Agent-loop `inference` lines whose `stop_reason` is `"max_tokens"`, written since
138    /// `task_start`: `task_end.capped_turns`. Reset with the other per-task counters.
139    task_capped_turns: u32,
140    task_start_instant: Option<Instant>,
141    pub(crate) active_task_id: Option<String>,
142    /// How many `task_failed` lines this writer has written, so a caller can tell whether one run
143    /// wrote its own without comparing reasons.
144    task_failures_written: u64,
145    /// The `reason` of the last `task_failed` line, as written (already capped).
146    last_task_failure: Option<String>,
147    /// The spend ceilings `session_start` records. Set by [`Self::set_spend_ceilings`].
148    max_session_tokens: Option<u64>,
149    machine_tokens_per_day: Option<u64>,
150    /// What `session-status` reads, fed by this writer's `inference`, `task_start` and `task_end`
151    /// writes and named by `session_start.deadline`. Set by [`Self::set_session_status`]; `None`
152    /// feeds nothing.
153    session_status: Option<std::sync::Arc<crate::session_status::SessionStatus>>,
154    /// The session's record of component calls the wall-clock bound stopped, drained into
155    /// `call_deadline` lines ahead of every line this writer writes. Set by
156    /// [`Self::set_call_deadlines`]; `None` drains nothing.
157    call_deadlines: Option<crate::limits::CallDeadlines>,
158    /// The session's record of requests routed to a member's virtual address, drained into
159    /// `member_request` lines at the same points as [`Self::call_deadlines`]. Set by
160    /// [`Self::set_member_requests`]; `None` drains nothing.
161    member_requests: Option<crate::member_request::MemberRequests>,
162}
163
164/// Provenance of an inference record that did **not** come from the agent
165/// loop's own turn — today, only a hook's `run-inference` call. `None` at a
166/// `write_inference`/`emit_inference` call site means "ordinary agent-loop
167/// turn", which serializes exactly as it did before this existed.
168#[derive(Debug, Clone)]
169pub(crate) struct InferenceOrigin {
170    /// `hook:<manifest name>` of the hook that made the call.
171    pub(crate) source: String,
172    /// Model string actually sent (the attempted model, on failure).
173    pub(crate) model: String,
174}
175
176/// How one `transport: http` call was sent, as its `inference` line records it.
177#[derive(Debug, Clone, PartialEq)]
178pub(crate) struct InferenceCallSettings {
179    /// The output cap the request carried as `max_tokens`, and the amount the spend meter
180    /// reserved for it.
181    pub(crate) max_output_tokens: u32,
182    /// The input budget the call was measured against, `min(input ceiling, context window −
183    /// max_output_tokens)`; `None` when its model has no window.
184    pub(crate) input_budget: Option<u32>,
185    /// The `MURMUR_INFERENCE_DRIVER_CONFIG` the call's driver ran with, from
186    /// [`recorded_driver_config`]. `None` when the driver saw none.
187    pub(crate) driver_config: Option<Value>,
188}
189
190/// The `MURMUR_INFERENCE_DRIVER_CONFIG` in `env`, as an `inference` line records it: `None` when
191/// `env` has none, otherwise its JSON with every object key and string leaf passed through
192/// `redact`, at any depth. A number leaf whose decimal text `redact` changes becomes the string
193/// [`crate::gateway_credential::REDACTED`]; booleans and nulls are kept as they are.
194///
195/// A value that is not JSON is recorded as `redact(value)`, a JSON string: it is what the driver
196/// saw. The manifest parser only produces JSON objects here.
197pub(crate) fn recorded_driver_config(
198    env: &[(String, String)],
199    redact: impl Fn(&str) -> String,
200) -> Option<Value> {
201    fn redact_value(value: Value, redact: &impl Fn(&str) -> String) -> Value {
202        match value {
203            Value::String(text) => Value::String(redact(&text)),
204            Value::Number(number) => {
205                let text = number.to_string();
206                if redact(&text) == text {
207                    Value::Number(number)
208                } else {
209                    Value::String(crate::gateway_credential::REDACTED.to_string())
210                }
211            }
212            Value::Array(items) => Value::Array(
213                items
214                    .into_iter()
215                    .map(|item| redact_value(item, redact))
216                    .collect(),
217            ),
218            Value::Object(entries) => Value::Object(
219                entries
220                    .into_iter()
221                    .map(|(key, item)| (redact(&key), redact_value(item, redact)))
222                    .collect(),
223            ),
224            other @ (Value::Bool(_) | Value::Null) => other,
225        }
226    }
227    let (_, raw) = env
228        .iter()
229        .find(|(key, _)| key == "MURMUR_INFERENCE_DRIVER_CONFIG")?;
230    Some(match serde_json::from_str::<Value>(raw) {
231        Ok(value) => redact_value(value, &redact),
232        Err(_) => Value::String(redact(raw)),
233    })
234}
235
236/// `compaction_declined.reason` when the threshold was crossed but no bound hook returned
237/// `replace-context`.
238pub(crate) const COMPACTION_DECLINED_NO_HOOK_REPLACEMENT: &str = "no_hook_replacement";
239
240/// `compaction_declined.reason` when a hook's replacement was rejected because its tool calls
241/// and tool results did not pair up.
242pub(crate) const COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL: &str = "unresolved_tool_call";
243
244/// `context_seed.outcome` when the whole proposal fit the budget and was committed as-is.
245pub(crate) const SEED_OUTCOME_SEEDED: &str = "seeded";
246
247/// `context_seed.outcome` when the proposal was over budget and the oldest messages were
248/// dropped from its front until the rest fit.
249pub(crate) const SEED_OUTCOME_TRIMMED: &str = "trimmed";
250
251/// `context_seed.outcome` when the overflowing front was handed to the compaction hook and
252/// its summary became the seed's first message.
253pub(crate) const SEED_OUTCOME_COMPACTED: &str = "compacted";
254
255/// `context_seed.outcome` when nothing was committed. Always paired with a `reason`.
256pub(crate) const SEED_OUTCOME_REJECTED: &str = "rejected";
257
258/// `context_seed.reason` when one message alone is wider than the whole budget. Trimming
259/// cannot help — dropping everything else still leaves it over — so nothing is committed.
260pub(crate) const SEED_REJECTED_MESSAGE_OVER_BUDGET: &str = "message_over_budget";
261
262/// `context_seed.reason` when the proposal overflows the budget by more multiples of it than
263/// the runtime will summarize. A seed that far over is a broken hook, not a full memory.
264pub(crate) const SEED_REJECTED_OVERFLOW_OVER_LIMIT: &str = "overflow_over_limit";
265
266/// `context_seed.reason` when the capsule declares no `context.max_tokens`, so
267/// `context.seed_budget` is a fraction of nothing and there is no ceiling to enforce.
268pub(crate) const SEED_REJECTED_NO_BUDGET: &str = "no_budget";
269
270/// `context_seed.reason` when the session runs `inference.transport: process`, which owns its
271/// own context and has no host-side message list to seed.
272pub(crate) const SEED_REJECTED_UNSUPPORTED_TRANSPORT: &str = "unsupported_transport";
273
274/// `retention.store` for the session directories under a workdir.
275pub(crate) const RETENTION_STORE_SESSIONS: &str = "sessions";
276
277/// `retention.store` for the conversation records under `~/.murmur/conversations/`.
278pub(crate) const RETENTION_STORE_RECORDS: &str = "records";
279
280/// `retention.reason` when `trace.retain.max_sessions` put a session outside the newest N.
281pub(crate) const RETENTION_REASON_MAX_SESSIONS: &str = "max_sessions";
282
283/// `retention.reason` when `trace.retain.max_age` or `context.retain.max_age` put a session or a
284/// record outside the window.
285pub(crate) const RETENTION_REASON_MAX_AGE: &str = "max_age";
286
287/// `retention.reason` when `context.retain.max_messages` truncated the front of a record.
288pub(crate) const RETENTION_REASON_MAX_MESSAGES: &str = "max_messages";
289
290/// Mint one event identity: `evt_` + a UUID v7 in simple form, the same scheme `ses_`, `tsk_`,
291/// `ctx_`, `dep_` and `req_` use. Ids therefore sort by mint time and carry their own
292/// millisecond timestamp, so a trace can be ordered and time-bounded without reading any
293/// payload field.
294///
295/// Called once per line at the moment of the write. An id is never reused, never derived from
296/// the event's content, and never reconstructed from anything else in the file.
297pub(crate) fn new_event_id() -> String {
298    format!("evt_{}", uuid::Uuid::now_v7().simple())
299}
300
301// ── Event structs (Serialize → JSONL lines) ──────────────────────────────────
302//
303// Every struct opens with `event_type`, `event_id`, `parent_id`, `session_id` and `timestamp`,
304// in that order, so a human scanning the file reads a line's identity before its payload.
305// `parent_id` is the `event_id` of the event this one hangs off — see [`TraceWriter`]'s
306// `session_parent`/`task_parent`/`turn_parent` for which node each event names, and
307// [`new_event_id`] for the id format.
308//
309// The turn-level events (`inference`, `tool_call`, `skill_call`, `shell`, `compaction`,
310// `compaction_declined`) additionally carry `task_id`, taken from the writer's active task and
311// always written — `null` when no task is in scope. `task_start`/`task_end` carry their own and
312// are not duplicated.
313
314#[derive(Serialize)]
315struct SessionStartEvent {
316    event_type: &'static str,
317    event_id: String,
318    parent_id: Option<String>,
319    session_id: String,
320    timestamp: u64,
321    capsule_name: String,
322    capsule_version: String,
323    model: String,
324    /// `inference.max_turns`. Always written, `null` when no cap applies.
325    max_turns: Option<u32>,
326    /// The primary driver choice's output cap and context window in force, and where each came
327    /// from. Always written, `null` under `transport: process`, which resolves none.
328    model_limits: Option<crate::model_limits::ResolvedModelLimits>,
329    /// `inference.max_session_tokens` as this session enforces it. Always written, `null` when
330    /// no session ceiling applies.
331    max_session_tokens: Option<u64>,
332    /// `spend.machine_tokens_per_day` when this session keeps the machine ledger. Always written,
333    /// `null` when the machine ceiling does not cover this session.
334    machine_tokens_per_day: Option<u64>,
335    /// The deadline this session runs under: the earliest of its own `mur run --deadline` and the
336    /// one it inherited. Always written, `null` when it has neither.
337    deadline: Option<SessionStartDeadline>,
338    capabilities: Vec<String>,
339    tools_declared: Vec<String>,
340    /// Strongest containment class any source asked for. A *requirement*, not an observation.
341    containment_declared: ContainmentClass,
342    /// What this session's commands actually ran under: the class of the tier it installed (the
343    /// probed host tier capped by the declared floor), capped at `advisory` by `workdir_exec` below
344    /// or a runtime directory inside the workdir. Reading these two together is how a session's
345    /// trace shows whether the capsule ran under the containment its operator asked for.
346    containment_achieved: ContainmentClass,
347    /// `capabilities.filesystem.workdir_exec`. Always written, including `false`, so its absence
348    /// identifies a trace from a runtime that predates the key rather than a capsule that declined
349    /// it.
350    ///
351    /// `true` is the reason a trace can show `containment_achieved: advisory` on a host whose
352    /// `session_start` would otherwise say `scoped` — and it is the record that this session's
353    /// `capabilities.shell.allow` was advisory too, since anything in the workdir could run.
354    workdir_exec: bool,
355    /// Where this host's permission to create an unprivileged user namespace came from, as
356    /// `UsernsGrant::wire_name` — `null` only off Linux, where AppArmor does not exist.
357    ///
358    /// Mirrored to the top level from `effective_grants` for the same reason `workdir_exec` is:
359    /// an auditor should not have to descend into a nested object to answer it. Recorded because
360    /// `containment_achieved: sealed` reached through the shipped AppArmor profile and the same
361    /// class reached on a host whose unprivileged-userns hardening is switched off for every
362    /// binary are two very different records, and this key is the only thing separating them.
363    ///
364    /// Always written, on the same terms as `workdir_exec`: its absence identifies a trace from a
365    /// runtime that predates the key.
366    userns_grant: Option<crate::sealed::UsernsGrant>,
367    /// The complete grant set this session ran under — every destination, binary, path and
368    /// environment variable the policy actually opened, plus the enforcement tier installed — in the
369    /// exact shape `mur run --explain-scope --json` prints for the same policy on the same host.
370    ///
371    /// `capabilities` above stays what it always was: a list of category *names*. It answers "did
372    /// this session have network access?" but not "to where?", which is the question an auditor
373    /// reading a finished trace actually has. This field answers it without re-parsing the
374    /// manifest, which by then may have changed or moved.
375    effective_grants: ScopeReport,
376    /// Where the system prompt this session ran with came from: `"manifest"` when the manifest's
377    /// own `inference.system_prompt`/`system_prompt_file`/`system_prompt_artifact` supplied it,
378    /// `"cli"` when `mur run --system-prompt` overrode it (including when the override was empty
379    /// and therefore cleared it), `"none"` when no prompt was in effect at all.
380    ///
381    /// Always written, on the same terms as `workdir_exec`: its absence identifies a trace from a
382    /// runtime that predates the key, not a session that had no prompt.
383    system_prompt_source: &'static str,
384    /// SHA-256 (lowercase hex) of the resolved prompt — the text as `resolve_system_prompt`
385    /// returned it, before the `[Capsule]` identity block is prepended — or `null` when no prompt
386    /// was in effect. Always written, so two sessions can be compared for prompt equality without
387    /// either trace having to carry the prompt itself.
388    ///
389    /// Deliberately not the same value as `inference.system_sha`, which covers the augmented
390    /// prompt that actually went on the wire: this one names the prompt the operator wrote.
391    /// Under `trace.capture: content` those resolved bytes are also the blob this hash names.
392    system_prompt_sha256: Option<String>,
393    /// Where the inference credential this session attaches came from: `"config"`,
394    /// `"environment"`, `"manifest"` or `"none"`. Never the value.
395    credential_source: &'static str,
396    /// Every credential gateway the session holds, the inference gateway first. Always written,
397    /// and empty for a session with none, so an auditor can tell an unmetered third-party
398    /// upstream from spend the ceilings cover.
399    gateways: Vec<SessionGateway>,
400    /// What a controller holding this session's control token may change: the setting names and
401    /// secret names `control:` declares. Omitted entirely for a capsule with no `control:` block.
402    #[serde(skip_serializing_if = "Option::is_none")]
403    control: Option<SessionControl>,
404    /// Every driver choice the agent loop may be switched to, the primary first, each with its
405    /// credential's source and whether it resolved at launch. Omitted entirely for a capsule that
406    /// declares no `inference.alternates`.
407    #[serde(skip_serializing_if = "Vec::is_empty")]
408    inference_choices: Vec<SessionInferenceChoice>,
409    /// `inference.tool_refresh`: `"compaction"` or `"immediate"` under `transport: http`, `null`
410    /// under `transport: process`, where the harness owns its tool list. Always written.
411    tool_refresh: Option<&'static str>,
412    /// Every staged artifact whose `murmur.lock` origin is `runtime` — pinned by a running
413    /// capsule's `manage.pull()`, never vetted by the operator — in staging order. Each reaches
414    /// the model marked untrusted. Always written, `[]` when there are none.
415    runtime_artifacts: Vec<RuntimeArtifact>,
416    /// One object per allowlisted binary staging examined, saying whether its facts came from a
417    /// kept `~/.murmur/host_facts` entry or were worked out, and why. Always written, `[]` when
418    /// no binary was examined.
419    host_facts: Vec<HostFactsRecord>,
420    /// One object per `inference.instruction_files` entry, in manifest order: its `path` as
421    /// written and its `status`, with `resolved`, `bytes`, `included_bytes`, `truncated` and
422    /// `sha256` (of the included text) on a `read` entry, `same_as` on a `same_file` one and
423    /// `error` on an `unreadable` one. `null` for a capsule without the key, `[]` for an empty
424    /// list. Always written.
425    instruction_files: Option<Vec<crate::instruction_files::InstructionFileRecord>>,
426    /// The session `mur run --resume` continued, or `null` on an ordinary launch. Together with
427    /// `context_id` below it is what makes a resumed conversation followable back through the
428    /// sessions that built it.
429    ///
430    /// Always written, on the same terms as `workdir_exec`: its absence identifies a trace from a
431    /// runtime that predates the key.
432    resumed_from: Option<String>,
433    /// The context id every task of this launch runs under — the `mur run --context` value, or
434    /// the id `--resume` resolved to — and `null` when each task mints its own. `task_start`
435    /// carries the id a task actually ran under either way.
436    ///
437    /// Always written, on the same terms as `resumed_from`.
438    context_id: Option<String>,
439    /// The `ses_` id of the session that spawned this one, for a capsule launched by another
440    /// capsule's `delegate-task`. Omitted from the record entirely for every other launch: the
441    /// field is absent, not null.
442    #[serde(skip_serializing_if = "Option::is_none")]
443    spawned_by: Option<String>,
444    /// The `dlg_` id of the delegation that created this session, character-identical to the id
445    /// in the spawning session's own `delegation_start`. Present exactly when `spawned_by` is.
446    #[serde(skip_serializing_if = "Option::is_none")]
447    delegation_id: Option<String>,
448    /// The formation this session is a member of. Omitted entirely for a session in no formation,
449    /// absent rather than null, on the same terms as `spawned_by`. Being on `session_start`, it is
450    /// on the trace's first line, which is the only line a reader grouping sessions reads.
451    #[serde(skip_serializing_if = "Option::is_none")]
452    formation_id: Option<FormationId>,
453    /// This session's roster name, for a session its formation launcher handed credentials to.
454    /// Omitted for every other session, a delegated child of a member included.
455    #[serde(skip_serializing_if = "Option::is_none")]
456    formation_member: Option<String>,
457    /// The members this one may call, in roster order: `[]` for a member that may call nobody.
458    /// Present exactly when `formation_member` is.
459    #[serde(skip_serializing_if = "Option::is_none")]
460    formation_callees: Option<Vec<String>>,
461}
462
463/// `session_start.deadline`.
464#[derive(Serialize)]
465struct SessionStartDeadline {
466    /// When the runtime ends the session, RFC 3339 UTC.
467    at: String,
468    /// Seconds from `mur run`'s start to `at`: the `--deadline` value, or for an inherited
469    /// deadline the whole seconds left at the start, rounded up.
470    secs: u64,
471    /// The notice margin in force, `lifecycle.deadline_notice_secs` or its default; `0` sends
472    /// none.
473    notice_secs: u64,
474    /// `SessionDeadline::source`: `"own"` or `"inherited"`.
475    source: &'static str,
476}
477
478#[derive(Serialize)]
479struct InferenceEvent {
480    event_type: &'static str,
481    event_id: String,
482    parent_id: Option<String>,
483    session_id: String,
484    timestamp: u64,
485    turn: u32,
486    task_id: Option<String>,
487    /// The input this turn cost, and the number the task and session totals accumulate.
488    ///
489    /// Under `transport: http` it is the runtime's own tiktoken estimate of the request, counted
490    /// before the request was sent, and the provider's own count sits beside it in
491    /// `input_tokens_actual`. Under `transport: process` the runtime estimates nothing — the
492    /// harness holds the conversation — so this *is* the harness's own reported count and
493    /// `input_tokens_actual` is absent: there is one measurement on that transport and it is
494    /// recorded once.
495    ///
496    /// Absent means no count was made at all: a `process` driver reporting no usage, or a failed
497    /// call that is not counted (see [`TraceWriter::write_failed_inference`]). Never conflate it
498    /// with `0`, which is a count of none.
499    #[serde(skip_serializing_if = "Option::is_none")]
500    input_tokens: Option<u64>,
501    /// The output this turn cost, on the same terms as [`Self::input_tokens`]: the runtime's own
502    /// tiktoken estimate of the raw driver response under `http`, the harness's own reported
503    /// count under `process`.
504    #[serde(skip_serializing_if = "Option::is_none")]
505    output_tokens: Option<u64>,
506    decision: String,
507    /// The provider's own stop reason for this turn, verbatim as the agent loop dispatched on
508    /// it. Present on every agent-loop turn, including one whose driver reported nothing — an
509    /// empty string then, because that is what the loop dispatched on. `"error"` on every failed
510    /// call, on either transport and for a hook's `run-inference`. Otherwise absent means no
511    /// driver response was parsed at all: a hook's `run-inference` and the `process` transport
512    /// both name their `decision` locally and never saw a provider stop reason.
513    #[serde(skip_serializing_if = "Option::is_none")]
514    stop_reason: Option<String>,
515    tool_name: Option<String>,
516    /// The provider's own counts for this call, each written only when the driver reported
517    /// it. Absent means the driver reported nothing, never zero.
518    #[serde(skip_serializing_if = "Option::is_none")]
519    input_tokens_actual: Option<u64>,
520    #[serde(skip_serializing_if = "Option::is_none")]
521    output_tokens_actual: Option<u64>,
522    #[serde(skip_serializing_if = "Option::is_none")]
523    cached_tokens: Option<u64>,
524    #[serde(skip_serializing_if = "Option::is_none")]
525    cache_write_tokens: Option<u64>,
526    /// The part of the output the provider or harness reports as reasoning — a subset of
527    /// `output_tokens`, never an addition to it. Absent when nothing reported one.
528    #[serde(skip_serializing_if = "Option::is_none")]
529    thinking_tokens: Option<u64>,
530    /// Where this inference came from. Absent for an ordinary agent-loop turn,
531    /// so every pre-existing consumer sees a byte-identical record; `"hook:<name>"`
532    /// for a completion that hook ran through `murmur:runtime/inference`'s
533    /// `run-inference`.
534    #[serde(skip_serializing_if = "Option::is_none")]
535    origin: Option<String>,
536    /// The driver choice that served an agent-loop turn, by name. Written only for a capsule that
537    /// declares `inference.alternates`, beside `model`.
538    #[serde(skip_serializing_if = "Option::is_none")]
539    driver_choice: Option<String>,
540    /// Model string actually sent for this call (the attempted model, on failure). Written
541    /// alongside `origin` for a hook's completion, and alongside `driver_choice` for an agent-loop
542    /// turn of a capsule that declares alternates. Any other turn's model is the session-start
543    /// record's and is not repeated per turn.
544    #[serde(skip_serializing_if = "Option::is_none")]
545    model: Option<String>,
546    /// The output cap this request carried as `max_tokens`, which is also what the spend meter
547    /// reserved for it. Written on every `transport: http` line — agent-loop turn, hook
548    /// `run-inference` and failed call alike — and on no `transport: process` line, where the
549    /// harness runs the model and the runtime sets no cap.
550    #[serde(skip_serializing_if = "Option::is_none")]
551    max_output_tokens: Option<u32>,
552    /// The call's input budget, `min(input ceiling, context window − max_output_tokens)`.
553    /// Present exactly when `max_output_tokens` is; `null` there means the model has no window.
554    /// The outer `Option` is the key, the inner one its value.
555    #[serde(skip_serializing_if = "Option::is_none")]
556    input_budget: Option<Option<u32>>,
557    /// The `MURMUR_INFERENCE_DRIVER_CONFIG` this call's driver ran with, every credential value
558    /// the session holds replaced with `[redacted]`. Present exactly when `max_output_tokens` is;
559    /// `null` there means the driver saw no config. The outer `Option` is the key, the inner one
560    /// its value.
561    #[serde(skip_serializing_if = "Option::is_none")]
562    driver_config: Option<Option<Value>>,
563    /// Identities of the messages this request embedded, in the order they sat in it. Under an
564    /// active driver continuation only the tail past the acked length is sent, and this names
565    /// exactly that tail. Empty — and therefore absent — for a hook's own `run-inference` and for
566    /// the `process` transport, neither of which sends a message list the runtime minted.
567    #[serde(skip_serializing_if = "Vec::is_empty")]
568    message_ids: Vec<String>,
569    /// SHA-256 (lowercase hex) of the UTF-8 bytes of this request's `system` string — the
570    /// augmented prompt with the `[Capsule]` block already in it.
571    ///
572    /// These are the bytes Murmur **sent**, not what the model **saw**: provider-side injection,
573    /// tokenizer differences and safety layers all happen past the wire and are invisible to the
574    /// runtime. Absent under `trace.capture: none`, and for a record the runtime did not build
575    /// the payload for — a hook's `run-inference`, and the `process` transport.
576    #[serde(skip_serializing_if = "Option::is_none")]
577    system_sha: Option<String>,
578    /// SHA-256 (lowercase hex) of this request's serialized `tools` array. The bytes Murmur
579    /// sent, on the same terms as [`Self::system_sha`].
580    #[serde(skip_serializing_if = "Option::is_none")]
581    tools_sha: Option<String>,
582    /// SHA-256 (lowercase hex) of the raw driver response body, as the loop read it before
583    /// parsing. What the driver returned to Murmur, byte for byte.
584    #[serde(skip_serializing_if = "Option::is_none")]
585    response_sha: Option<String>,
586    /// SHA-256 (lowercase hex) of each message this request embedded, in send order — one entry
587    /// per `message_ids` entry, over the same messages after the runtime's identity keys are
588    /// stripped. The bytes Murmur sent, on the same terms as [`Self::system_sha`].
589    ///
590    /// Not redundant with `message_ids`: an id names an entity and is freshly minted every run,
591    /// so comparing two runs' id arrays only says every id differs. Comparing these says where
592    /// the two prompts stopped agreeing — the divergence index is the first unequal position.
593    #[serde(skip_serializing_if = "Vec::is_empty")]
594    message_shas: Vec<String>,
595    /// Why the call failed, from the `INFERENCE_ERROR_*` vocabulary. Present exactly on a record
596    /// whose `decision` and `stop_reason` are both `"error"`; absent on every call that returned
597    /// an answer, however unusable the loop then found it.
598    #[serde(skip_serializing_if = "Option::is_none")]
599    error_code: Option<String>,
600    /// The failure in words, present exactly when `error_code` is. Every credential value the
601    /// session holds is already replaced with `[redacted]`, and the text is capped at
602    /// [`MAX_TASK_FAILURE_REASON_BYTES`] on a character boundary.
603    #[serde(skip_serializing_if = "Option::is_none")]
604    error: Option<String>,
605    /// The HTTP status the runtime's credential gateway received for the failed call. Absent when
606    /// no request reached the provider, on the `process` transport, and for a driver whose
607    /// gateway the runtime does not own.
608    #[serde(skip_serializing_if = "Option::is_none")]
609    provider_status: Option<u16>,
610    /// Milliseconds from the start of the attempt this record describes until the driver's
611    /// request reached the runtime's credential gateway. Present exactly on a call made through
612    /// a runtime-owned gateway; `null` there means no request reached it. The outer `Option` is
613    /// the key, the inner one its value.
614    #[serde(skip_serializing_if = "Option::is_none")]
615    gateway_reached_ms: Option<Option<u64>>,
616    /// Milliseconds from the start of the attempt until the provider's response head arrived at
617    /// the gateway, the last one when the gateway resent. Present exactly when
618    /// `gateway_reached_ms` is; `null` there means no response head arrived.
619    #[serde(skip_serializing_if = "Option::is_none")]
620    upstream_responded_ms: Option<Option<u64>>,
621    /// How many attempts the call took, written only when it took more than one. Each retry
622    /// before the last attempt has its own `inference_retry` line.
623    #[serde(skip_serializing_if = "Option::is_none")]
624    attempts: Option<u32>,
625    /// Why a call whose last attempt failed transiently was not made again:
626    /// [`crate::inference_retry::RetryStop::wire_name`]. Absent on every other record.
627    #[serde(skip_serializing_if = "Option::is_none")]
628    retry_stopped: Option<&'static str>,
629}
630
631/// The bodies one driver request put on the wire, split into the pieces an `inference` event
632/// hashes.
633///
634/// Built from the payload [`crate::agent::build_driver_payload`] produced and the raw response
635/// string the driver returned, so what the trace names and what the driver received cannot
636/// drift: there is no second serialization for them to drift apart in.
637///
638/// Every piece is the bytes Murmur **sent** (or, for the response, was handed back), never what
639/// the model saw.
640pub(crate) struct WireCapture {
641    /// The payload's `system` string value as UTF-8 text — the augmented prompt, `[Capsule]`
642    /// block included. Stored as text so its blob `cat`s as a readable prompt.
643    system: Vec<u8>,
644    /// The payload's `tools` array, serialized.
645    tools: Vec<u8>,
646    /// Each element of the payload's `messages` array, serialized, in send order. That array is
647    /// the `wire_messages` slice after `strip_message_identity`, so no message identity key can
648    /// reach a hash or a blob.
649    messages: Vec<Vec<u8>>,
650    /// The raw driver response body, exactly as the loop read it.
651    response: Vec<u8>,
652}
653
654impl WireCapture {
655    /// Split a built driver payload and its raw response into the four bodies the trace hashes.
656    ///
657    /// `payload` must be the value that was serialized and handed to the driver; taking the
658    /// pieces out of it, rather than rebuilding them from the loop's own state, is what makes a
659    /// blob byte-identical to the substring of the request it came from.
660    pub(crate) fn from_driver_payload(payload: &Value, response: &str) -> Self {
661        Self {
662            system: payload
663                .get("system")
664                .and_then(Value::as_str)
665                .unwrap_or_default()
666                .as_bytes()
667                .to_vec(),
668            tools: payload
669                .get("tools")
670                .map(|tools| serde_json::to_vec(tools).unwrap_or_default())
671                .unwrap_or_default(),
672            messages: payload
673                .get("messages")
674                .and_then(Value::as_array)
675                .map(|messages| {
676                    messages
677                        .iter()
678                        .map(|message| serde_json::to_vec(message).unwrap_or_default())
679                        .collect()
680                })
681                .unwrap_or_default(),
682            response: response.as_bytes().to_vec(),
683        }
684    }
685}
686
687#[derive(Serialize)]
688struct ToolCallEvent {
689    event_type: &'static str,
690    event_id: String,
691    parent_id: Option<String>,
692    session_id: String,
693    timestamp: u64,
694    turn: u32,
695    task_id: Option<String>,
696    tool_name: String,
697    /// The provider's own id for this call, as it appeared on the driver response block that
698    /// asked for it. Recorded verbatim and never parsed — the same contract `resource_id` has.
699    /// `null` when the provider named none, which is how every host-synthesized call reads.
700    tool_call_id: Option<String>,
701    input: Value,
702    input_bytes: u64,
703    #[serde(skip_serializing_if = "Option::is_none")]
704    output: Option<String>,
705    output_bytes: u64,
706    duration_ms: u64,
707    status: String,
708    /// The runtime's own text when it could not run the call, such as a shell
709    /// command whose sandbox setup failed before exec. Written under every [`TraceCapture`],
710    /// unlike `output`; peer handle tokens are redacted and the text is capped at
711    /// [`MAX_TASK_FAILURE_REASON_BYTES`] on a character boundary. Absent when the tool ran,
712    /// including when it reported its own failure.
713    #[serde(skip_serializing_if = "Option::is_none")]
714    error: Option<String>,
715    /// The tool's self-declared `state_effect` for this call (`read`/`mutate`),
716    /// lifted verbatim from `tool-result.metadata`. Absent when the tool declared
717    /// nothing; consumers treat absence conservatively. See `wit/tool.wit`.
718    #[serde(skip_serializing_if = "Option::is_none")]
719    state_effect: Option<String>,
720    /// The resource this call addressed, as declared by the tool and lifted verbatim
721    /// from `tool-result.metadata`. An opaque, tool-defined string — never parsed here.
722    /// Absent when the tool declared nothing, in which case consumers fall back to
723    /// guessing the resource from the call's input. See `wit/tool.wit`.
724    #[serde(skip_serializing_if = "Option::is_none")]
725    resource_id: Option<String>,
726    /// The images the call returned, accepted or refused, in the order it named them. Absent
727    /// when it returned none. Never holds image bytes.
728    #[serde(skip_serializing_if = "Vec::is_empty")]
729    attachments: Vec<AttachmentRecord>,
730}
731
732/// One image a tool call returned or a task message carried, as the trace records it.
733///
734/// An accepted image carries `bytes` and `sha256`, the name of its blob beside the conversation;
735/// a refused one carries `refused`, the refusal code its note ends with. `path` is the work-folder
736/// path a tool named, or `message.parts[<i>]` for an image an A2A message carried.
737#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
738pub(crate) struct AttachmentRecord {
739    pub(crate) path: String,
740    pub(crate) media_type: String,
741    #[serde(skip_serializing_if = "Option::is_none")]
742    pub(crate) bytes: Option<u64>,
743    #[serde(skip_serializing_if = "Option::is_none")]
744    pub(crate) sha256: Option<String>,
745    #[serde(skip_serializing_if = "Option::is_none")]
746    pub(crate) refused: Option<&'static str>,
747}
748
749#[derive(Serialize)]
750struct SkillCallEvent {
751    event_type: &'static str,
752    event_id: String,
753    parent_id: Option<String>,
754    session_id: String,
755    timestamp: u64,
756    turn: u32,
757    task_id: Option<String>,
758    skill_name: String,
759    output_bytes: u64,
760    duration_ms: u64,
761    status: String,
762    /// The skill's `murmur.lock` origin: `"operator"` or `"runtime"`. Always written.
763    origin: &'static str,
764    /// `"trusted"` for an operator pin, `"untrusted"` for a runtime one, whose guidance reached
765    /// the model fenced. Always written, in the spelling `task_start.trust` uses.
766    trust: &'static str,
767}
768
769#[derive(Serialize)]
770struct ShellEvent {
771    event_type: &'static str,
772    event_id: String,
773    parent_id: Option<String>,
774    session_id: String,
775    timestamp: u64,
776    turn: u32,
777    task_id: Option<String>,
778    /// The program that ran — a canonical absolute path when the host `PATH` resolved
779    /// the invoked name, else the bare name. `command` carries the argument list alone,
780    /// so this is the only field that says *what* ran.
781    binary: String,
782    command: String,
783    exit_code: i32,
784    stdout_bytes: u64,
785    stderr_bytes: u64,
786    duration_ms: u64,
787    /// The `capabilities.resources` field this subprocess was killed for exceeding, when the
788    /// kernel's own evidence names exactly one (`SIGXCPU`/`SIGXFSZ`, or a cgroup
789    /// `memory.events`/`pids.events` counter that moved). Omitted from the JSONL entirely
790    /// otherwise — including for a subprocess that died for a reason no single limit can be
791    /// pinned to, which must not read as "no limit was involved".
792    #[serde(skip_serializing_if = "Option::is_none")]
793    resource_limit: Option<String>,
794}
795
796/// A shell command that outran its grace period and moved to the background. Hangs off the turn
797/// that started it, so `mur trace steps` shows it where the model saw it.
798#[derive(Serialize)]
799struct ShellDetachedEvent {
800    event_type: &'static str,
801    event_id: String,
802    parent_id: Option<String>,
803    session_id: String,
804    timestamp: u64,
805    turn: u32,
806    task_id: Option<String>,
807    work_id: String,
808    binary: String,
809    command: String,
810    /// The `lifecycle.shell_grace_secs` in force, in milliseconds — what this command outran.
811    /// Absent when the session declares no grace, so the command was demoted by an abandoned
812    /// call or a cancel.
813    #[serde(skip_serializing_if = "Option::is_none")]
814    grace_ms: Option<u64>,
815}
816
817/// A detached command's completion reaching the agent, written once per work id by whichever path
818/// delivered it: [`ResourceTraceAppender::write_shell_completed`] right after the
819/// `background_command` line of the call that returned the finished state, or
820/// [`TraceWriter::write_shell_completed`] where the task loop queues it as a task. Hangs off the
821/// session rather than a turn: the turn that started the command may be long over.
822#[derive(Serialize)]
823struct ShellCompletedEvent {
824    event_type: &'static str,
825    event_id: String,
826    parent_id: Option<String>,
827    session_id: String,
828    timestamp: u64,
829    work_id: String,
830    binary: String,
831    command: String,
832    exit_code: i32,
833    duration_ms: u64,
834    /// Epoch milliseconds at which the runtime recorded the command's exit, which is never after
835    /// [`Self::timestamp`].
836    exited_at_ms: u64,
837    /// Absolute path of the file holding the command's full stdout and stderr,
838    /// `logs/<work_id>.log` in the session's records directory.
839    output_path: String,
840    output_bytes: u64,
841    /// Attributed the same way [`ShellEvent::resource_limit`] is, and omitted on the same terms.
842    #[serde(skip_serializing_if = "Option::is_none")]
843    resource_limit: Option<String>,
844    /// `"ok"` or `"error"` — the field that tells a failed background command from a successful
845    /// one without reading its prose.
846    status: String,
847    /// [`crate::detached::Delivery::as_str`]: `"background_command"` or `"task"`.
848    delivered_via: &'static str,
849    /// The `completion`-origin task this completion was enqueued as, which is what makes "which
850    /// command produced this task" answerable from `trace.jsonl` alone. Present exactly when
851    /// [`Self::delivered_via`] is `"task"`.
852    #[serde(skip_serializing_if = "Option::is_none")]
853    completion_task_id: Option<String>,
854}
855
856impl ShellCompletedEvent {
857    fn new(
858        session_id: String,
859        parent_id: Option<String>,
860        completion: &crate::detached::DetachedCompletion,
861        delivery: crate::detached::Delivery,
862        completion_task_id: Option<&str>,
863    ) -> Self {
864        Self {
865            event_type: "shell_completed",
866            event_id: new_event_id(),
867            parent_id,
868            session_id,
869            timestamp: timestamp_ms(),
870            work_id: completion.work_id.clone(),
871            binary: completion.binary.clone(),
872            command: completion.command.clone(),
873            exit_code: completion.exit_code,
874            duration_ms: completion.duration_ms,
875            exited_at_ms: completion.exited_at_ms,
876            output_path: completion.output_path.clone(),
877            output_bytes: completion.output_bytes,
878            resource_limit: completion.resource_limit.clone(),
879            status: completion.status().to_string(),
880            delivered_via: delivery.as_str(),
881            completion_task_id: completion_task_id.map(str::to_string),
882        }
883    }
884}
885
886/// One answered `background-command` call. Hangs off the session, as `session_status` does.
887#[derive(Serialize)]
888struct BackgroundCommandEvent<'a> {
889    event_type: &'static str,
890    event_id: String,
891    parent_id: Option<String>,
892    session_id: String,
893    timestamp: u64,
894    /// The task that made the call. Absent outside a task.
895    #[serde(skip_serializing_if = "Option::is_none")]
896    task_id: Option<String>,
897    work_id: &'a str,
898    /// `status`, `wait` or `stop`.
899    operation: &'static str,
900    /// `running`, `exited` or `stopped`, as the call returned it.
901    state: &'static str,
902    /// Present once the command has exited.
903    #[serde(skip_serializing_if = "Option::is_none")]
904    exit_code: Option<i32>,
905    /// How long a `wait` blocked. Absent for the other operations.
906    #[serde(skip_serializing_if = "Option::is_none")]
907    waited_ms: Option<u64>,
908    /// Bytes in the log's output section, as the call returned it.
909    output_bytes: u64,
910}
911
912/// The fields of a `process_start` line: a program a WASM tool started through `murmur:process`.
913#[derive(Serialize)]
914pub(crate) struct ProcessStartLine<'a> {
915    /// The task whose tool call started it. Absent outside a task.
916    #[serde(skip_serializing_if = "Option::is_none")]
917    pub(crate) task_id: Option<String>,
918    /// The tool that started it.
919    pub(crate) tool: &'a str,
920    /// The session process's handle, or the `prc_` id minted for one `run`.
921    pub(crate) process_id: &'a str,
922    /// `run` or `session`.
923    pub(crate) kind: &'static str,
924    /// The tool's name for a session process; null for a `run`.
925    pub(crate) name: Option<&'a str>,
926    /// The program, resolved against the host `PATH`.
927    pub(crate) binary: &'a str,
928    /// The arguments, without the program.
929    pub(crate) argv: &'a [String],
930    /// The process's pid, which also names its process group.
931    pub(crate) pid: i32,
932}
933
934#[derive(Serialize)]
935struct ProcessStartEvent<'a> {
936    event_type: &'static str,
937    event_id: String,
938    parent_id: Option<String>,
939    session_id: String,
940    timestamp: u64,
941    #[serde(flatten)]
942    line: ProcessStartLine<'a>,
943}
944
945/// The fields of a `process_exit` line.
946#[derive(Serialize)]
947pub(crate) struct ProcessExitLine<'a> {
948    #[serde(skip_serializing_if = "Option::is_none")]
949    pub(crate) task_id: Option<String>,
950    pub(crate) tool: &'a str,
951    pub(crate) process_id: &'a str,
952    pub(crate) kind: &'static str,
953    pub(crate) name: Option<&'a str>,
954    pub(crate) binary: &'a str,
955    /// The exit code; a death by signal `n` is `128 + n`.
956    pub(crate) exit_code: i32,
957    /// `exited`, `stopped`, `timed_out` or `session_end`.
958    pub(crate) reason: &'static str,
959    pub(crate) duration_ms: u64,
960}
961
962#[derive(Serialize)]
963struct ProcessExitEvent<'a> {
964    event_type: &'static str,
965    event_id: String,
966    parent_id: Option<String>,
967    session_id: String,
968    timestamp: u64,
969    #[serde(flatten)]
970    line: ProcessExitLine<'a>,
971}
972
973/// The fields of a `process_refused` line.
974#[derive(Serialize)]
975pub(crate) struct ProcessRefusedLine<'a> {
976    #[serde(skip_serializing_if = "Option::is_none")]
977    pub(crate) task_id: Option<String>,
978    pub(crate) tool: &'a str,
979    /// `run`, `session` or `connect`.
980    pub(crate) kind: &'static str,
981    /// The session process named; null for a `run`.
982    pub(crate) name: Option<&'a str>,
983    /// The program, resolved; absent for `connect`.
984    #[serde(skip_serializing_if = "Option::is_none")]
985    pub(crate) binary: Option<&'a str>,
986    /// The port asked for; present for `connect` only.
987    #[serde(skip_serializing_if = "Option::is_none")]
988    pub(crate) port: Option<u16>,
989    pub(crate) rule: &'static str,
990    pub(crate) reason: &'a str,
991}
992
993#[derive(Serialize)]
994struct ProcessRefusedEvent<'a> {
995    event_type: &'static str,
996    event_id: String,
997    parent_id: Option<String>,
998    session_id: String,
999    timestamp: u64,
1000    #[serde(flatten)]
1001    line: ProcessRefusedLine<'a>,
1002}
1003
1004/// A detached command the session ended without carrying its result back. No task will ever report
1005/// it; this record and the operator report beside it are what keep the loss visible.
1006///
1007/// The command was either still running at the sweep or finished during teardown, and the second
1008/// case knows more: [`Self::exit_code`], [`Self::output_path`] and [`Self::output_bytes`] are
1009/// written together for it and omitted together for the first. A command still running therefore
1010/// produces a line byte-identical in shape to one written before those fields existed — omitted,
1011/// never `null`, because `null` would read as a known-absent exit code rather than an unknown one.
1012#[derive(Serialize)]
1013struct ShellAbandonedEvent {
1014    event_type: &'static str,
1015    event_id: String,
1016    parent_id: Option<String>,
1017    session_id: String,
1018    timestamp: u64,
1019    work_id: String,
1020    binary: String,
1021    command: String,
1022    /// How long the command had been running when the session gave up on it. For a command that
1023    /// finished during teardown, its full duration from spawn to exit.
1024    running_ms: u64,
1025    /// `128 + signal` for a signal kill, as on `shell_completed`. Absent for a command still
1026    /// running.
1027    #[serde(skip_serializing_if = "Option::is_none")]
1028    exit_code: Option<i32>,
1029    /// Absolute path of the file holding the command's full stdout and stderr,
1030    /// `logs/<work_id>.log` in the session's records directory. Absent for a command still
1031    /// running, for which that file was never written and never will be.
1032    #[serde(skip_serializing_if = "Option::is_none")]
1033    output_path: Option<String>,
1034    /// Size of that file. Present exactly when [`Self::output_path`] is.
1035    #[serde(skip_serializing_if = "Option::is_none")]
1036    output_bytes: Option<u64>,
1037}
1038
1039/// A demoted command a later launch found unaccounted for, appended to the `trace.jsonl` of the
1040/// session that started it.
1041///
1042/// Carries no `exit_code`, no `status`, no `duration_ms`, no `output_path` and no `output_bytes`:
1043/// none of them exists for a command whose runtime died, and a record that named any of them
1044/// would be readable as a result. The event type is the whole discriminator against
1045/// [`ShellCompletedEvent`].
1046///
1047/// Its presence is also what stops the same work id being reported by a second resume, so this
1048/// line and the `shell_detached` it answers live in the same file and are pruned together by
1049/// [`crate::retention::prune_sessions`].
1050#[derive(Serialize)]
1051struct ShellLostEvent {
1052    event_type: &'static str,
1053    event_id: String,
1054    /// The prior session's own `session_start` node. Omitted from the line when that record could
1055    /// not be read back — a line must never name a parent the file does not hold.
1056    #[serde(skip_serializing_if = "Option::is_none")]
1057    parent_id: Option<String>,
1058    /// The prior session's id, not the resuming one's, so the file stays internally consistent.
1059    session_id: String,
1060    timestamp: u64,
1061    work_id: String,
1062    binary: String,
1063    command: String,
1064    /// The `shell_detached` line's own timestamp.
1065    detached_at_ms: u64,
1066    /// The session that found the work unaccounted for and reported it.
1067    reconciled_by_session: String,
1068    /// The `completion`-origin task that session enqueued to report it, which is what makes "which
1069    /// task reported this loss" answerable from the two files alone.
1070    reconciled_task_id: String,
1071}
1072
1073/// A demotion whose own `shell_detached` line could not be written.
1074///
1075/// Best-effort and usually absent: it goes to the file that just failed. When it does land, it is
1076/// the record that the work id below has no `shell_detached` line and so will never be reported
1077/// as lost.
1078#[derive(Serialize)]
1079struct ShellDetachUnrecordedEvent {
1080    event_type: &'static str,
1081    event_id: String,
1082    parent_id: Option<String>,
1083    session_id: String,
1084    timestamp: u64,
1085    turn: u32,
1086    task_id: Option<String>,
1087    work_id: String,
1088    binary: String,
1089    /// The write error, as the operating system reported it.
1090    reason: String,
1091}
1092
1093/// A tool call the driver attached safety checks to, and how they ended. Written once per such
1094/// call, after the person answered (or could not be asked) and, for an acknowledged call, before
1095/// its `tool_call` record. The person's reply is never written.
1096#[derive(Serialize)]
1097struct SafetyCheckEvent {
1098    event_type: &'static str,
1099    event_id: String,
1100    parent_id: Option<String>,
1101    session_id: String,
1102    timestamp: u64,
1103    turn: u32,
1104    task_id: Option<String>,
1105    tool_call_id: String,
1106    tool: String,
1107    /// The block's `safety_checks` exactly as the driver sent it, a malformed one included.
1108    checks: serde_json::Value,
1109    /// `acknowledged`, `declined`, `timed_out`, `canceled`, `no_input_channel` or `malformed`.
1110    outcome: &'static str,
1111}
1112
1113#[derive(Serialize)]
1114struct CompactionEvent {
1115    event_type: &'static str,
1116    event_id: String,
1117    parent_id: Option<String>,
1118    session_id: String,
1119    timestamp: u64,
1120    turn: u32,
1121    task_id: Option<String>,
1122    /// The occupancy the trigger compared: the provider's count of the request and its
1123    /// completion, or the estimate when the call reported none.
1124    tokens_before: u64,
1125    /// Which kind of number `tokens_before` is.
1126    tokens_before_source: OccupancyBasis,
1127    /// The estimate of the committed context. No provider has counted it yet; the next
1128    /// `inference` line carries the first count.
1129    tokens_after: u64,
1130}
1131
1132/// The compaction threshold was crossed and the context was left as it was. The session
1133/// continues over budget, so the record of *why* is the only thing separating this from a
1134/// session that never needed compacting at all.
1135#[derive(Serialize)]
1136struct CompactionDeclinedEvent {
1137    event_type: &'static str,
1138    event_id: String,
1139    parent_id: Option<String>,
1140    session_id: String,
1141    timestamp: u64,
1142    turn: u32,
1143    task_id: Option<String>,
1144    /// Context occupancy at the moment of the decline — the same measurement `compaction`
1145    /// records as `tokens_before`, and the budget the session went on running over.
1146    tokens: u64,
1147    /// Which kind of number `tokens` is.
1148    tokens_source: OccupancyBasis,
1149    /// `"no_hook_replacement"` when no bound hook returned `replace-context`;
1150    /// `"unresolved_tool_call"` when the replacement's tool calls and tool results did not pair.
1151    reason: String,
1152}
1153
1154/// An `on-task-start` hook proposed context and the runtime decided what to do with it.
1155///
1156/// Written once per task that had a seeding hook return something, whatever the decision —
1157/// including a rejection, where `tokens` is `0`. A seed that was silently discarded is
1158/// indistinguishable from a capsule with no memory hook at all, which is the failure this
1159/// record exists to make impossible.
1160#[derive(Serialize)]
1161struct ContextSeedEvent {
1162    event_type: &'static str,
1163    event_id: String,
1164    parent_id: Option<String>,
1165    session_id: String,
1166    timestamp: u64,
1167    task_id: Option<String>,
1168    /// Manifest name of the hook that returned the `seed-context`.
1169    hook_name: String,
1170    /// Tokens actually committed to the head of the context: `0` for a rejection, the
1171    /// proposal's own count for `seeded`, and the surviving count for `trimmed`/`compacted`.
1172    tokens: u64,
1173    /// Tokens the hook proposed, before any trim or summarization. Together with
1174    /// `budget_tokens` this is what makes a rejection readable without the hook's own log.
1175    proposed_tokens: u64,
1176    /// The ceiling in force: `floor(context.max_tokens * context.seed_budget)`, or `0` when
1177    /// the capsule declares no `context.max_tokens`.
1178    budget_tokens: u64,
1179    /// `"seeded"`, `"trimmed"`, `"compacted"` or `"rejected"` — see the `SEED_OUTCOME_*`
1180    /// constants.
1181    outcome: String,
1182    /// Why nothing was committed, on `"rejected"` only — see the `SEED_REJECTED_*`
1183    /// constants. Absent from the JSONL for every other outcome.
1184    #[serde(skip_serializing_if = "Option::is_none")]
1185    reason: Option<String>,
1186    /// The `msg_`-prefixed id of every message committed, in the order they were placed.
1187    /// Empty on a rejection. These are the ids the runtime minted; they never reach the
1188    /// driver payload, so this record is the only place they are visible.
1189    message_ids: Vec<String>,
1190}
1191
1192/// Retention deleted something. One line per (store, reason) pair that removed anything, in the
1193/// trace of the session that performed the deletion — the only place the pruning of session N's
1194/// predecessors can go.
1195///
1196/// Session-scoped, and written immediately after `session_start`: a session directory that
1197/// vanishes with no explanation makes "where did my trace go" unanswerable, and the answer has to
1198/// be in the trace of the run that did it.
1199#[derive(Serialize)]
1200struct RetentionEvent {
1201    event_type: &'static str,
1202    event_id: String,
1203    parent_id: Option<String>,
1204    session_id: String,
1205    timestamp: u64,
1206    /// [`RETENTION_STORE_SESSIONS`] or [`RETENTION_STORE_RECORDS`].
1207    store: String,
1208    /// [`RETENTION_REASON_MAX_SESSIONS`], [`RETENTION_REASON_MAX_AGE`] or
1209    /// [`RETENTION_REASON_MAX_MESSAGES`].
1210    reason: String,
1211    /// How many units this reason removed from this store: session directories, context
1212    /// directories, or — for a truncation — the one record that was rewritten. Never zero;
1213    /// nothing removed means no event.
1214    removed: u32,
1215    /// What went: `ses_` directory names, or context ids.
1216    targets: Vec<String>,
1217    /// Messages dropped from the front of the record. Written for
1218    /// [`RETENTION_REASON_MAX_MESSAGES`] only, and absent from the JSONL otherwise.
1219    #[serde(skip_serializing_if = "Option::is_none")]
1220    messages_dropped: Option<u64>,
1221}
1222
1223#[derive(Serialize)]
1224struct A2aTaskReceivedEvent {
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    message_id: String,
1233    traceparent_from_caller: Option<String>,
1234    /// Who the door let the task in as: [`RECEIVED_FROM_OPERATOR`], [`RECEIVED_FROM_MEMBER`],
1235    /// [`RECEIVED_FROM_CREDENTIAL`] or [`RECEIVED_FROM_PUBLIC`].
1236    caller: &'static str,
1237    /// The formation member that called, present exactly with `caller: "member"`.
1238    #[serde(skip_serializing_if = "Option::is_none")]
1239    caller_member: Option<String>,
1240    /// The declared credential whose token let the task in, present exactly with
1241    /// `caller: "credential"`.
1242    #[serde(skip_serializing_if = "Option::is_none")]
1243    caller_credential: Option<String>,
1244    /// The caller's `member_request` id, present only with `caller: "member"` and only when the
1245    /// request carried a well-formed one.
1246    #[serde(skip_serializing_if = "Option::is_none")]
1247    member_request_id: Option<String>,
1248    /// The `sha256` of the task's message text, omitted under `capture: none`. Under `content`
1249    /// the text is stored as the blob of that name.
1250    #[serde(skip_serializing_if = "Option::is_none")]
1251    message_sha256: Option<String>,
1252}
1253
1254/// `a2a_task_received.caller` for a task the operator token let in.
1255pub(crate) const RECEIVED_FROM_OPERATOR: &str = "operator";
1256/// `a2a_task_received.caller` for a task a formation token let in.
1257pub(crate) const RECEIVED_FROM_MEMBER: &str = "member";
1258/// `a2a_task_received.caller` for a task a declared credential's token let in.
1259pub(crate) const RECEIVED_FROM_CREDENTIAL: &str = "credential";
1260/// `a2a_task_received.caller` for a task a door with no `network.authentication` let in.
1261pub(crate) const RECEIVED_FROM_PUBLIC: &str = "public";
1262
1263#[derive(Serialize)]
1264struct A2aSendEvent {
1265    event_type: &'static str,
1266    event_id: String,
1267    parent_id: Option<String>,
1268    session_id: String,
1269    timestamp: u64,
1270    peer_url: String,
1271    message_id: String,
1272    task_id: String,
1273    context_id: String,
1274    traceparent: Option<String>,
1275    /// The class this runtime stamped on the outgoing request, so a chain of capsules reads the
1276    /// same way from the sending end as the receiving end's `task_start` reads it.
1277    trust: String,
1278}
1279
1280/// A successful pull, by `manage.pull()` or `pull-artifact`: the artifact a running capsule
1281/// fetched and pinned in `murmur.lock`. A refused or failed pull writes nothing.
1282#[derive(Serialize)]
1283struct ArtifactPulledEvent {
1284    event_type: &'static str,
1285    event_id: String,
1286    parent_id: Option<String>,
1287    session_id: String,
1288    timestamp: u64,
1289    name: String,
1290    version: String,
1291    /// `"skill"` or `"tool"`, the only roles a pull installs.
1292    runtime: &'static str,
1293    /// The origin the lock entry holds after the pull: `"runtime"`, or `"operator"` when the
1294    /// operator had already pinned the same version.
1295    origin: &'static str,
1296    /// The session the lock entry names as the puller — this one, unless an earlier pull of the
1297    /// same pin recorded another. `null` for an `"operator"` entry.
1298    session: Option<String>,
1299    trust: &'static str,
1300    /// Where the payload came from: `"project store"`, `"global store"` or
1301    /// `"github:<owner>/<repo>"`.
1302    fetched_from: String,
1303    /// GitHub's numeric id of the scope's owner, written to the lock entry beside the pin.
1304    /// Absent when neither the source nor the stored payload records one.
1305    #[serde(skip_serializing_if = "Option::is_none")]
1306    owner_id: Option<u64>,
1307}
1308
1309#[derive(Serialize)]
1310struct SessionEndEvent {
1311    event_type: &'static str,
1312    event_id: String,
1313    parent_id: Option<String>,
1314    session_id: String,
1315    timestamp: u64,
1316    total_turns: u32,
1317    total_input_tokens: u64,
1318    total_output_tokens: u64,
1319    total_tool_calls: u32,
1320    total_shell_calls: u32,
1321    duration_ms: u64,
1322    exit_status: String,
1323}
1324
1325#[derive(Serialize)]
1326struct TaskStartEvent {
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    context_id: String,
1334    /// Which door the task came through. `source` and `origin` answer different questions —
1335    /// `"a2a"` covers both a trusted peer and an untyped webhook.
1336    source: String,
1337    origin: String,
1338    trust: String,
1339    /// The queue lane this task waited in, derived from `origin`. Recorded so the order two
1340    /// tasks ran in is answerable from the trace alone.
1341    lane: String,
1342    message_parts_bytes: u64,
1343    /// The kind of each part of the A2A message the task arrived as, `text`, `data` or `raw`, in
1344    /// order. Absent for a task that did not arrive over the door.
1345    #[serde(skip_serializing_if = "Vec::is_empty")]
1346    part_kinds: Vec<&'static str>,
1347    /// The message's `referenceTaskIds`, each once. Absent when it referenced none.
1348    #[serde(skip_serializing_if = "Vec::is_empty")]
1349    reference_task_ids: Vec<String>,
1350    /// The images the message's `raw` parts carried, each with its `path` as
1351    /// `message.parts[<i>]`. Absent when it carried none.
1352    #[serde(skip_serializing_if = "Vec::is_empty")]
1353    attachments: Vec<AttachmentRecord>,
1354}
1355
1356#[derive(Serialize)]
1357struct TaskEndEvent {
1358    event_type: &'static str,
1359    event_id: String,
1360    parent_id: Option<String>,
1361    session_id: String,
1362    timestamp: u64,
1363    task_id: String,
1364    exit_status: String,
1365    duration_ms: u64,
1366    turns: u32,
1367    input_tokens: u64,
1368    output_tokens: u64,
1369    tool_calls: u32,
1370    shell_calls: u32,
1371    /// How many times this task's agent loop was reopened by an `on-task-end` hook
1372    /// before the terminal outcome. `0` for a task that ran once (the common case).
1373    reopen_count: u32,
1374    /// How many of this task's agent-loop turns stopped at the `inference.max_tokens` output
1375    /// cap. Always present; `0` when none did.
1376    capped_turns: u32,
1377    /// The work ids of the background shell commands still running when the task ended, oldest
1378    /// first. Absent when there were none.
1379    #[serde(skip_serializing_if = "Vec::is_empty")]
1380    running_commands: Vec<String>,
1381    /// How `lifecycle.keep_working` went for this task. Absent when the capsule does not set it.
1382    #[serde(skip_serializing_if = "Option::is_none")]
1383    keep_working: Option<KeepWorkingSummary>,
1384    /// How `lifecycle.done_check` went for this task. Absent when the capsule does not set it.
1385    #[serde(skip_serializing_if = "Option::is_none")]
1386    done_check: Option<DoneCheckSummary>,
1387}
1388
1389/// `task_end.done_check`: the runs of `lifecycle.done_check`'s command for this task, the reopens
1390/// they caused and whether the last run passed.
1391#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1392pub(crate) struct DoneCheckSummary {
1393    /// Runs, across reopens.
1394    pub(crate) runs: u32,
1395    /// Reopens the failing runs caused.
1396    pub(crate) reopens: u32,
1397    /// `lifecycle.done_check.limit`.
1398    pub(crate) limit: u32,
1399    /// Whether the last run passed: `null` when the check never ran, `false` after a run a cancel
1400    /// or the session deadline stopped.
1401    pub(crate) met: Option<bool>,
1402    /// What ended the task with the check unmet: `limit`, `max_turns`, `spend_ceiling` or
1403    /// `deadline`. `null` otherwise.
1404    pub(crate) stopped_by: Option<&'static str>,
1405}
1406
1407/// `task_end.keep_working`: the continuations `lifecycle.keep_working` sent this task and what
1408/// stopped them.
1409#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1410pub(crate) struct KeepWorkingSummary {
1411    /// Continuations sent, across reopens.
1412    pub(crate) used: u32,
1413    /// `lifecycle.keep_working.limit`.
1414    pub(crate) limit: u32,
1415    /// What stopped the task being kept working: `limit`, `max_turns`, `spend_ceiling`,
1416    /// `deadline` or `no_session`. `null` when it ended on anything else — a failure, a cancel, a
1417    /// turn end that handed off work.
1418    pub(crate) stopped_by: Option<&'static str>,
1419}
1420
1421/// A person stopped this task. Written where the loop actually stopped, so the record names the
1422/// wait that was interrupted and everything that was still running at that moment.
1423///
1424/// Distinct from the `task_end` that follows it: `task_end` says the task is over and with which
1425/// `exit_status`, this says a person made that happen and what it left behind. A task cancelled
1426/// while still queued has no `task_start` and gets no `task_end`, so this is its only record.
1427#[derive(Serialize)]
1428struct TaskCanceledEvent {
1429    event_type: &'static str,
1430    event_id: String,
1431    parent_id: Option<String>,
1432    session_id: String,
1433    timestamp: u64,
1434    task_id: String,
1435    /// The turn that was in flight, 0-based. Absent for a task cancelled before it ever ran.
1436    #[serde(skip_serializing_if = "Option::is_none")]
1437    turn: Option<u32>,
1438    /// Where cancellation landed: `queued`, `turn`, `inference`, `input`, `delegation` or
1439    /// `harness`.
1440    phase: String,
1441    /// Demoted shell commands still running when the loop stopped. They keep their own
1442    /// lifecycle — nothing here was killed.
1443    detached_work_ids: Vec<String>,
1444    /// Delegations in flight when the loop stopped, named before the cancelled task ends them.
1445    delegation_ids: Vec<String>,
1446}
1447
1448/// `task_rejected.cause` when the session ended on its own — close-out, `after_task: exit`, a
1449/// failed launch task or the idle timeout — with the task still queued.
1450pub(crate) const TASK_REJECTED_SESSION_ENDED: &str = "session_ended";
1451
1452/// `task_rejected.cause` when `mur stop` (`SIGTERM`) ended the session with the task still queued.
1453pub(crate) const TASK_REJECTED_SESSION_STOPPED: &str = "session_stopped";
1454
1455/// The session stopped taking work while this task was still queued, so it was refused rather
1456/// than run. Written once per refused task, after the task loop has ended and before
1457/// `session_end`.
1458///
1459/// A refused task never started, so it has no `task_start`, no `task_end` and never a
1460/// `task_failed`: this is its only record. It hangs off the session node.
1461#[derive(Serialize)]
1462struct TaskRejectedEvent {
1463    event_type: &'static str,
1464    event_id: String,
1465    parent_id: Option<String>,
1466    session_id: String,
1467    timestamp: u64,
1468    task_id: String,
1469    context_id: String,
1470    /// The `IncomingTask::source` the task was enqueued with: `a2a`, `detached_shell` or
1471    /// `detached_lost`.
1472    source: String,
1473    /// One of the `TASK_REJECTED_*` constants.
1474    cause: String,
1475    /// The `status.message` the task's final `rejected` frame carries.
1476    reason: String,
1477}
1478
1479/// `task_failed.cause` when the driver returned an error status or a response whose
1480/// `stop_reason` is `"error"`, and no credential rejection is pending.
1481pub(crate) const TASK_FAILED_DRIVER_ERROR: &str = "driver_error";
1482
1483/// `task_failed.cause` for a driver error while the gateway holds a credential rejection: the
1484/// provider keeps refusing the key, and the reason is the `E-RUN-027` message.
1485pub(crate) const TASK_FAILED_CREDENTIAL_REJECTED: &str = "credential_rejected";
1486
1487/// `task_failed.cause` for a response the loop cannot act on: `stop_reason: "tool_call"` with no
1488/// `tool_call` block, or a `stop_reason` that is missing or unsupported.
1489pub(crate) const TASK_FAILED_MALFORMED_RESPONSE: &str = "malformed_response";
1490
1491/// `task_failed.cause` when a bound compaction hook returned `Err`.
1492pub(crate) const TASK_FAILED_COMPACTION_HOOK: &str = "compaction_hook";
1493
1494/// `task_failed.cause` when a `request-input` wait passed `lifecycle.input_timeout_secs`.
1495pub(crate) const TASK_FAILED_INPUT_TIMEOUT: &str = "input_timeout";
1496
1497/// `task_failed.cause` when an `on-task-end` hook still asked to reopen and
1498/// `lifecycle.max_task_reopens` or `inference.max_turns` refused it.
1499pub(crate) const TASK_FAILED_REOPEN_BUDGET_EXHAUSTED: &str = "reopen_budget_exhausted";
1500
1501/// `task_failed.cause` for an attempt that ended in a runtime error no other cause names. The
1502/// reason is the error's own text.
1503pub(crate) const TASK_FAILED_RUNTIME_ERROR: &str = "runtime_error";
1504
1505/// `task_failed.cause` when two agent-loop turns in a row stopped at the `inference.max_tokens`
1506/// output cap with no text and no tool call. The reason names both turns and the cap.
1507pub(crate) const TASK_FAILED_OUTPUT_CAP: &str = "output_cap";
1508
1509/// `task_failed.cause` for a task ended because a runtime write, append, read, create or removal
1510/// inside the workdir was refused. Always preceded by one `workdir_path_refused` line per refusal.
1511pub(crate) const TASK_FAILED_WORKDIR_PATH_REFUSED: &str = "workdir_path_refused";
1512
1513/// `inference.error_code` when the provider refused the inference credential: it answered `401`
1514/// and the gateway's re-read and resend were refused too. The record's `error` is the runtime's
1515/// own `E-RUN-027` message, never the provider's body.
1516pub(crate) const INFERENCE_ERROR_CREDENTIAL_REJECTED: &str = TASK_FAILED_CREDENTIAL_REJECTED;
1517
1518/// `inference.error_code` when the driver reported the call failed and no credential rejection is
1519/// pending: a provider HTTP error, the driver's own error, a gateway refusal surfaced to the
1520/// driver, or a response whose `stop_reason` is `"error"`.
1521pub(crate) const INFERENCE_ERROR_DRIVER_ERROR: &str = TASK_FAILED_DRIVER_ERROR;
1522
1523/// `inference.error_code` when the driver component could not be invoked or trapped:
1524/// instantiation or import failure, the execution deadline, or a memory limit.
1525pub(crate) const INFERENCE_ERROR_DRIVER_FAILED: &str = "driver_failed";
1526
1527/// `inference.error_code` when the driver reported success but returned no body, or a body that
1528/// is not JSON.
1529pub(crate) const INFERENCE_ERROR_MALFORMED_RESPONSE: &str = TASK_FAILED_MALFORMED_RESPONSE;
1530
1531/// `inference.error_code` when a `process` harness reported a `turn-failed` of kind `auth`.
1532pub(crate) const INFERENCE_ERROR_HARNESS_AUTH: &str = "harness_auth";
1533
1534/// `inference.error_code` when a `process` harness reported a `turn-failed` of kind `quota`.
1535pub(crate) const INFERENCE_ERROR_HARNESS_QUOTA: &str = "harness_quota";
1536
1537/// `inference.error_code` when a `process` harness reported a `turn-failed` of kind
1538/// `harness-error`.
1539pub(crate) const INFERENCE_ERROR_HARNESS_ERROR: &str = "harness_error";
1540
1541/// `inference.error_code` when a `process` harness reported a `turn-failed` of kind `other`.
1542pub(crate) const INFERENCE_ERROR_HARNESS_OTHER: &str = "harness_other";
1543
1544/// `inference.error_code` when a `process` harness went silent past the inactivity timeout.
1545pub(crate) const INFERENCE_ERROR_HARNESS_INACTIVE: &str = "harness_inactive";
1546
1547/// One model call that failed, as [`TraceWriter::write_failed_inference`] records it.
1548pub(crate) struct FailedInference<'a> {
1549    pub(crate) turn: u32,
1550    /// One of the `INFERENCE_ERROR_*` constants.
1551    pub(crate) error_code: &'static str,
1552    /// The failure in words, already redacted. Capped by the writer.
1553    pub(crate) error: &'a str,
1554    /// The HTTP status the credential gateway received for this call, when it received one.
1555    pub(crate) provider_status: Option<u16>,
1556    /// What the provider itself reported, when the failure carried a `usage` block.
1557    pub(crate) usage: Option<&'a DriverUsage>,
1558    /// `Some` for a hook's `run-inference`; `None` for an agent-loop call.
1559    pub(crate) origin: Option<&'a InferenceOrigin>,
1560    /// The driver choice the call was dispatched to, on a capsule that declares alternates.
1561    pub(crate) choice: Option<&'a crate::driver_choice::DriverChoice>,
1562    /// The messages the failed request embedded.
1563    pub(crate) message_ids: Vec<String>,
1564    pub(crate) tool_name: Option<String>,
1565    /// `(input_tokens, output_tokens)` this call counts toward the turn and token totals. `None`
1566    /// is a call that is not counted at all — no turn, no tokens, and neither key on the record.
1567    pub(crate) spent: Option<(Option<u64>, Option<u64>)>,
1568    /// How the call was sent; `None` on the `process` transport.
1569    pub(crate) settings: Option<&'a InferenceCallSettings>,
1570    /// The call's attempts and why its retrying stopped, as its retry loop ended.
1571    pub(crate) retry: RetryNote,
1572    /// The last attempt's gateway timing; `None` for a call made through no runtime gateway.
1573    pub(crate) gateway: Option<GatewayOffsets>,
1574}
1575
1576/// One `inference` line, as the writers that share [`TraceWriter::write_inference_event`] hand
1577/// it over.
1578struct InferenceLine<'a> {
1579    turn: u32,
1580    input_tokens: Option<u64>,
1581    output_tokens: Option<u64>,
1582    decision: String,
1583    stop_reason: Option<&'a str>,
1584    tool_name: Option<String>,
1585    origin: Option<&'a InferenceOrigin>,
1586    usage: Option<&'a DriverUsage>,
1587    message_ids: Vec<String>,
1588    wire: Option<&'a WireCapture>,
1589    choice: Option<&'a crate::driver_choice::DriverChoice>,
1590    /// `(error_code, error, provider_status)` for a failed call.
1591    failure: Option<(&'static str, String, Option<u16>)>,
1592    /// Whether the line advances the turn and token counters.
1593    counted: bool,
1594    /// How the call was sent; `None` on the `process` transport, which writes neither key.
1595    settings: Option<&'a InferenceCallSettings>,
1596    retry: RetryNote,
1597    /// When the attempt's request reached the runtime's credential gateway and was answered;
1598    /// `None` for a call made through no runtime gateway, which writes neither key.
1599    gateway: Option<GatewayOffsets>,
1600}
1601
1602/// The most `task_failed.reason` carries, in bytes. A driver's error text is provider-supplied
1603/// and unbounded; the whole of it stays in `out/result.txt`.
1604pub(crate) const MAX_TASK_FAILURE_REASON_BYTES: usize = 2_000;
1605
1606/// One runtime write, append, read, create or removal inside the workdir that was refused and did
1607/// nothing. Written whatever `trace.capture` is; parented to the
1608/// task node in a task and to the session node outside one.
1609#[derive(Serialize)]
1610struct WorkdirPathRefusedEvent<'a> {
1611    event_type: &'static str,
1612    event_id: String,
1613    parent_id: Option<String>,
1614    session_id: String,
1615    timestamp: u64,
1616    #[serde(skip_serializing_if = "Option::is_none")]
1617    task_id: Option<String>,
1618    /// The path the runtime meant to touch, relative to the accessible workdir.
1619    path: &'a str,
1620    operation: crate::workdir_fs::WorkdirOperation,
1621    /// The component that was refused, relative to the accessible workdir; the whole path for an
1622    /// invalid component.
1623    refused_at: &'a str,
1624    reason: crate::workdir_fs::RefusalReason,
1625}
1626
1627/// A task attempt failed, and why. Written once per failing attempt, before its task's
1628/// `task_end`, by whichever part of the runtime saw the failure.
1629///
1630/// `task_end.exit_status` says `failed`; this names the cause from a closed vocabulary (the
1631/// `TASK_FAILED_*` constants) and carries the reason in words. Never written for a task that
1632/// ended `ok`, `max_turns_reached`, `spend_ceiling_reached` or `canceled`: those have their own
1633/// records.
1634#[derive(Serialize)]
1635struct TaskFailedEvent {
1636    event_type: &'static str,
1637    event_id: String,
1638    parent_id: Option<String>,
1639    session_id: String,
1640    timestamp: u64,
1641    /// The task in progress. Absent for a failure recorded before any `task_start`.
1642    #[serde(skip_serializing_if = "Option::is_none")]
1643    task_id: Option<String>,
1644    /// The turn that failed, 0-based. Absent when the failure is the task's rather than one
1645    /// turn's.
1646    #[serde(skip_serializing_if = "Option::is_none")]
1647    turn: Option<u32>,
1648    cause: String,
1649    /// Capped at [`MAX_TASK_FAILURE_REASON_BYTES`] on a character boundary.
1650    reason: String,
1651}
1652
1653/// `task_reopened.attempt_context` for a next attempt that continues the task's own
1654/// conversation, with the hook's feedback as the one message it adds.
1655pub(crate) const REOPEN_CONTEXT_CONTINUED: &str = "continued";
1656
1657/// `task_reopened.attempt_context` for a next attempt that starts from a fresh context built
1658/// from the rewritten `task.md`, because the previous attempt left no conversation to continue.
1659pub(crate) const REOPEN_CONTEXT_RESTARTED: &str = "restarted";
1660
1661/// One `on-task-end` hook reopened the task: its agent loop is about to run again with
1662/// the hook's feedback. Written between two agent-loop attempts for the same task, so
1663/// `mur trace show` can show which hook drove each reopen, why, and what the next attempt
1664/// starts from.
1665#[derive(Serialize)]
1666struct TaskReopenedEvent {
1667    event_type: &'static str,
1668    event_id: String,
1669    parent_id: Option<String>,
1670    session_id: String,
1671    timestamp: u64,
1672    task_id: String,
1673    /// Manifest name of the `on-task-end` hook that returned `reopen-task`.
1674    hook_name: String,
1675    /// Feedback text the hook asked to inject into the reopened task content.
1676    reason: String,
1677    /// 1-based ordinal of this reopen within the task (first reopen = 1).
1678    reopen_number: u32,
1679    /// [`REOPEN_CONTEXT_CONTINUED`] or [`REOPEN_CONTEXT_RESTARTED`].
1680    attempt_context: &'static str,
1681    /// The turns the next attempt is handed: `inference.max_turns` less every turn the task's
1682    /// attempts have spent so far. `null` when
1683    /// `inference.max_turns` sets no cap.
1684    turns_remaining: Option<u32>,
1685}
1686
1687/// A task continued once with everything it had handed off: its attempt ended with a turn left,
1688/// it waited until no `call-member` call and no `delegate-task` delegation was outstanding, and
1689/// the next attempt is handed every answer and outcome in one message. Written after that round's
1690/// `member_call` and `delegation` lines and before the continued attempt's first `inference`.
1691#[derive(Serialize)]
1692struct TaskContinuedEvent<'a> {
1693    event_type: &'static str,
1694    event_id: String,
1695    parent_id: Option<String>,
1696    session_id: String,
1697    timestamp: u64,
1698    task_id: String,
1699    /// 1-based ordinal of this continuation within the task, counted across reopens.
1700    continuation_number: u32,
1701    /// The `mcl_` id of every call whose outcome this continuation delivers, in arrival order.
1702    member_calls: &'a [String],
1703    /// The `dlg_` id of every delegation whose outcome this continuation delivers, arrived or
1704    /// ended by the backstop, in id order.
1705    delegations: &'a [String],
1706    /// How long the task waited, from its attempt's end until nothing was outstanding.
1707    waited_ms: u64,
1708    /// The turns the continued attempt is handed: `inference.max_turns` less every turn the
1709    /// task's attempts have spent so far. `null` when
1710    /// `inference.max_turns` sets no cap.
1711    turns_remaining: Option<u32>,
1712}
1713
1714/// `lifecycle.keep_working` sent its message after the task's attempt ended at a turn end. Written
1715/// after that turn's `inference` and before the continued attempt's first one.
1716#[derive(Serialize)]
1717struct KeepWorkingEvent {
1718    event_type: &'static str,
1719    event_id: String,
1720    parent_id: Option<String>,
1721    session_id: String,
1722    timestamp: u64,
1723    task_id: String,
1724    /// 1-based ordinal of this continuation within the task, counted across reopens.
1725    number: u32,
1726    /// `lifecycle.keep_working.limit`.
1727    limit: u32,
1728    /// The turns the continued attempt is handed: `inference.max_turns` less every turn the
1729    /// task's attempts have spent so far. `null` when `inference.max_turns` sets no cap.
1730    turns_remaining: Option<u32>,
1731    /// The byte length of the message sent.
1732    message_bytes: u64,
1733}
1734
1735/// One run of `lifecycle.done_check`'s command at a turn end. Written after that turn's
1736/// `inference` and any `keep_working` record, before the next attempt's first `inference` or the
1737/// task's `task_end`.
1738#[derive(Serialize)]
1739struct DoneCheckEvent<'a> {
1740    event_type: &'static str,
1741    event_id: String,
1742    parent_id: Option<String>,
1743    session_id: String,
1744    timestamp: u64,
1745    #[serde(flatten)]
1746    run: &'a DoneCheckRun<'a>,
1747}
1748
1749/// The fields of one `done_check` record that describe the run.
1750#[derive(Debug, Serialize)]
1751pub(crate) struct DoneCheckRun<'a> {
1752    pub(crate) task_id: &'a str,
1753    /// 1-based ordinal of this run within the task, counted across reopens.
1754    pub(crate) number: u32,
1755    /// `lifecycle.done_check.command` as declared.
1756    pub(crate) command: &'a str,
1757    /// `passed`, `failed`, `timed_out`, `stopped` or `not_started`.
1758    pub(crate) outcome: &'static str,
1759    /// `null` for `timed_out`, `stopped` and `not_started`.
1760    pub(crate) exit_code: Option<i32>,
1761    pub(crate) duration_ms: u64,
1762    /// Bytes returned after the 16 KiB-per-stream cap.
1763    pub(crate) stdout_bytes: u64,
1764    pub(crate) stderr_bytes: u64,
1765    pub(crate) truncated: bool,
1766    /// The `capabilities.resources` field whose limit killed the command.
1767    pub(crate) resource_limit: Option<&'a str>,
1768    /// Whether this run reopened the task.
1769    pub(crate) reopened: bool,
1770    /// The reopens the check may still cause in this task, after this run.
1771    pub(crate) reopens_left: u32,
1772    /// `inference.max_turns` less every turn the task has spent. `null` with no cap.
1773    pub(crate) turns_remaining: Option<u32>,
1774}
1775
1776/// A component call the wall-clock bound on `capabilities.limits.deadline_seconds` stopped while
1777/// it waited on the host. Written once per firing, before the record of the call it cut and
1778/// before `session_end`; never for an epoch trap.
1779#[derive(Serialize)]
1780struct CallDeadlineEvent {
1781    event_type: &'static str,
1782    event_id: String,
1783    parent_id: Option<String>,
1784    session_id: String,
1785    timestamp: u64,
1786    #[serde(skip_serializing_if = "Option::is_none")]
1787    task_id: Option<String>,
1788    /// [`crate::limits::CallDeadline::component`].
1789    component: String,
1790    /// [`crate::limits::ComponentRole::wire_name`].
1791    role: &'static str,
1792    /// [`crate::limits::CallDeadline::call`].
1793    call: String,
1794    deadline_seconds: u64,
1795    /// How long the stopped phase had run, from the start of that phase.
1796    elapsed_ms: u64,
1797}
1798
1799/// One request the runtime routed to a member's virtual address, refusals included. Written once
1800/// per request, after it was accounted for and before `session_end`. Never holds a door address,
1801/// a token, a header value or a body.
1802#[derive(Serialize)]
1803struct MemberRequestEvent {
1804    event_type: &'static str,
1805    event_id: String,
1806    parent_id: Option<String>,
1807    session_id: String,
1808    timestamp: u64,
1809    /// [`crate::member_request::MemberRequest::call_id`]: `mrq_` and 32 lowercase hex digits.
1810    call_id: String,
1811    /// The calling task; absent when the request was sent for none.
1812    #[serde(skip_serializing_if = "Option::is_none")]
1813    task_id: Option<String>,
1814    /// The roster name the virtual address names; absent when its host names no single member.
1815    #[serde(skip_serializing_if = "Option::is_none")]
1816    member: Option<String>,
1817    component: String,
1818    /// [`crate::limits::ComponentRole::wire_name`].
1819    role: &'static str,
1820    /// [`crate::member_request::MemberRequestVia::wire_name`].
1821    via: &'static str,
1822    /// `sent`, `refused` or `failed`.
1823    outcome: &'static str,
1824    /// Present exactly for `refused` and `failed`.
1825    #[serde(skip_serializing_if = "Option::is_none")]
1826    reason: Option<&'static str>,
1827    #[serde(skip_serializing_if = "Option::is_none")]
1828    http_status: Option<u16>,
1829    #[serde(skip_serializing_if = "Option::is_none")]
1830    member_task_id: Option<String>,
1831    duration_ms: u64,
1832}
1833
1834/// A driver call failed transiently and is about to be made again. The call's one `inference`
1835/// line follows its last attempt.
1836#[derive(Serialize)]
1837struct InferenceRetryEvent<'a> {
1838    event_type: &'static str,
1839    event_id: String,
1840    parent_id: Option<String>,
1841    session_id: String,
1842    timestamp: u64,
1843    turn: u32,
1844    task_id: Option<String>,
1845    attempt: u32,
1846    max_retries: u32,
1847    reason: &'static str,
1848    #[serde(skip_serializing_if = "Option::is_none")]
1849    provider_status: Option<u16>,
1850    error: &'a str,
1851    delay_ms: u64,
1852    #[serde(skip_serializing_if = "Option::is_none")]
1853    retry_after_ms: Option<u64>,
1854    charged_tokens: u64,
1855    #[serde(skip_serializing_if = "Option::is_none")]
1856    origin: Option<&'a str>,
1857}
1858
1859/// A spend ceiling refused a driver call before it was sent. No `inference` line accompanies it,
1860/// because no call was made.
1861#[derive(Serialize)]
1862struct SpendCeilingReachedEvent<'a> {
1863    event_type: &'static str,
1864    event_id: String,
1865    parent_id: Option<String>,
1866    session_id: String,
1867    timestamp: u64,
1868    turn: u32,
1869    task_id: Option<String>,
1870    #[serde(flatten)]
1871    refusal: &'a crate::spend::SpendRefusal,
1872    /// `hook:<name>` for a hook's `run-inference` call; absent for an agent-loop turn.
1873    #[serde(skip_serializing_if = "Option::is_none")]
1874    origin: Option<&'a str>,
1875}
1876
1877/// What a `transport: process` session spawned, and what the driver planned for it.
1878///
1879/// Separate from [`HarnessStartEvent`] so the runner builds one value rather than passing a dozen
1880/// positional arguments. Flattened into the event, so the record has no nested object.
1881#[derive(Serialize)]
1882pub(crate) struct HarnessStart {
1883    /// The process driver artifact and its version.
1884    pub driver: String,
1885    pub driver_version: String,
1886    /// What the driver said it drives, and the executable actually spawned.
1887    pub harness: String,
1888    /// The enforcement tier the harness ran under, as a stable wire name — the same spelling
1889    /// `ScopeReport::enforcement_tier` uses (`landlock+seccomp`, `mountns+pivot_root+landlock+seccomp`,
1890    /// `unconfined`, …).
1891    pub tier: String,
1892    /// The absolute path spawned — not the name the manifest or the driver asked for.
1893    pub binary: String,
1894    /// `inference.command`, or the process driver's `describe()`.
1895    pub binary_source: String,
1896    /// What the harness printed for its version arguments, or `null` when it could not be read.
1897    pub harness_version: Option<String>,
1898    /// Whether [`Self::harness_version`] matched one of the driver's `tested-versions`.
1899    pub version_tested: bool,
1900    /// The harness session id the runtime minted for this run, and whether it started or resumed.
1901    pub harness_session_id: String,
1902    pub session_mode: String,
1903    /// How many arguments the plan carried. Never the arguments themselves: a driver is free to
1904    /// put the bridge's bearer token in one.
1905    pub args_count: usize,
1906    /// The names of the variables actually delivered, sorted. Never their values.
1907    pub env_names: Vec<String>,
1908    /// Which of `USER` and `LOGNAME` the host did not set, sorted; empty when it set both. Never
1909    /// their values.
1910    pub account_env_unset: Vec<String>,
1911    /// The names of the files written into the run's private directory. Never their contents.
1912    pub files: Vec<String>,
1913    /// The capsule tool names the bridge offered, bare. Empty when no bridge was bound.
1914    pub bridge_tools: Vec<String>,
1915    /// How many bytes were written to the harness's stdin, and whether it stayed open after.
1916    pub stdin_bytes: usize,
1917    pub keep_stdin_open: bool,
1918    /// SHA-256 (lowercase hex) of the exact system prompt handed to the process driver: the
1919    /// fact lines, the resolved prompt and any instruction-file blocks. Unlike
1920    /// `session_start.system_prompt_sha256`, which names the prompt the operator wrote.
1921    pub system_prompt_sha256: String,
1922    /// The text [`Self::system_prompt_sha256`] hashes, stored as that blob under
1923    /// `trace.capture: content` and never written to the line.
1924    #[serde(skip)]
1925    pub system_prompt: String,
1926}
1927
1928#[derive(Serialize)]
1929struct HarnessStartEvent {
1930    event_type: &'static str,
1931    event_id: String,
1932    parent_id: Option<String>,
1933    session_id: String,
1934    timestamp: u64,
1935    task_id: Option<String>,
1936    #[serde(flatten)]
1937    start: HarnessStart,
1938}
1939
1940/// A `W-*` warning the run raised. The same text goes to stderr; this is the durable copy, so a
1941/// run read back later still says what it warned about.
1942#[derive(Serialize)]
1943struct HarnessWarningEvent {
1944    event_type: &'static str,
1945    event_id: String,
1946    parent_id: Option<String>,
1947    session_id: String,
1948    timestamp: u64,
1949    task_id: Option<String>,
1950    code: String,
1951    message: String,
1952}
1953
1954/// The session the harness itself reports, which is not this session: the runtime's `session_id`
1955/// names the capsule run, `harness_session_id` names the conversation inside the harness.
1956#[derive(Serialize)]
1957struct HarnessSessionEvent {
1958    event_type: &'static str,
1959    event_id: String,
1960    parent_id: Option<String>,
1961    session_id: String,
1962    timestamp: u64,
1963    task_id: Option<String>,
1964    harness_session_id: String,
1965    /// How the harness says it is billing. Anything but `subscription` is also warned about.
1966    auth: String,
1967    /// The model the harness says it is running, when it says.
1968    model: Option<String>,
1969}
1970
1971/// The harness session a person asked this capsule to drop, recorded because forgetting is not
1972/// the same as never having known: the turn that follows starts a new conversation under a context
1973/// that had one, and this is what says so.
1974#[derive(Serialize)]
1975struct HarnessSessionForgottenEvent {
1976    event_type: &'static str,
1977    event_id: String,
1978    parent_id: Option<String>,
1979    session_id: String,
1980    timestamp: u64,
1981    task_id: Option<String>,
1982    /// The context whose entry was dropped.
1983    context_id: String,
1984    /// The id that was dropped, which the harness is no longer asked to continue.
1985    harness_session_id: String,
1986    /// Which surface carried the request: `cli` or `a2a`.
1987    requested_by: String,
1988}
1989
1990#[derive(Serialize)]
1991struct HarnessRetryEvent {
1992    event_type: &'static str,
1993    event_id: String,
1994    parent_id: Option<String>,
1995    session_id: String,
1996    timestamp: u64,
1997    task_id: Option<String>,
1998    attempt: u32,
1999    reason: String,
2000}
2001
2002/// A task-node record whose payload is one line of text: `harness_note` and
2003/// `harness_suggestion`.
2004#[derive(Serialize)]
2005struct HarnessTextEvent {
2006    event_type: &'static str,
2007    event_id: String,
2008    parent_id: Option<String>,
2009    session_id: String,
2010    timestamp: u64,
2011    task_id: Option<String>,
2012    text: String,
2013}
2014
2015/// One subscription window the harness reported, as `harness_quota` records it. A member the
2016/// harness did not report is left out, never written as zero or `null`.
2017#[derive(Serialize)]
2018struct HarnessQuotaWindow<'a> {
2019    name: &'a str,
2020    #[serde(skip_serializing_if = "Option::is_none")]
2021    used: Option<f64>,
2022    #[serde(skip_serializing_if = "Option::is_none")]
2023    resets_at_ms: Option<u64>,
2024    #[serde(skip_serializing_if = "Option::is_none")]
2025    window_minutes: Option<u64>,
2026    #[serde(skip_serializing_if = "Option::is_none")]
2027    state: Option<&'static str>,
2028}
2029
2030#[derive(Serialize)]
2031struct HarnessQuotaEvent<'a> {
2032    event_type: &'static str,
2033    event_id: String,
2034    parent_id: Option<String>,
2035    session_id: String,
2036    timestamp: u64,
2037    task_id: Option<String>,
2038    windows: Vec<HarnessQuotaWindow<'a>>,
2039}
2040
2041#[derive(Serialize)]
2042struct HarnessFailedEvent {
2043    event_type: &'static str,
2044    event_id: String,
2045    parent_id: Option<String>,
2046    session_id: String,
2047    timestamp: u64,
2048    task_id: Option<String>,
2049    /// The driver's `failure-kind`, spelled as the WIT spells it, or `max-turns` from the runtime.
2050    kind: String,
2051    message: String,
2052    /// `harness` when the harness reported it, `runtime` when this runtime ended the turn.
2053    source: String,
2054}
2055
2056/// How the runtime tried to stop a harness a person cancelled the task of.
2057#[derive(Serialize)]
2058struct HarnessInterruptEvent {
2059    event_type: &'static str,
2060    event_id: String,
2061    parent_id: Option<String>,
2062    session_id: String,
2063    timestamp: u64,
2064    task_id: Option<String>,
2065    /// The driver's `interrupt-method`, spelled as the WIT spells it.
2066    method: String,
2067    /// Whether a graceful interrupt actually reached the harness.
2068    delivered: bool,
2069    /// How long the harness has to end on its own before it is killed. `0` when it is killed at
2070    /// once, which is every ending where `delivered` is false.
2071    grace_ms: u64,
2072}
2073
2074/// How one spawned harness ended. Written once per spawn, on every path out of the run.
2075#[derive(Serialize)]
2076pub(crate) struct HarnessExit {
2077    /// The process's exit code, or `null` when a signal ended it.
2078    pub code: Option<i32>,
2079    /// The signal that ended the process, or `null` when it exited on its own.
2080    pub signal: Option<i32>,
2081    /// What ended the run: `terminal`, `eof`, `inactivity`, `max_turns`, `driver_error` or
2082    /// `canceled`.
2083    pub cause: String,
2084    /// Whether the runtime had to kill the process rather than wait for it.
2085    pub killed: bool,
2086    /// Wall time from spawn to reaped.
2087    pub duration_ms: u64,
2088}
2089
2090#[derive(Serialize)]
2091struct HarnessExitEvent {
2092    event_type: &'static str,
2093    event_id: String,
2094    parent_id: Option<String>,
2095    session_id: String,
2096    timestamp: u64,
2097    task_id: Option<String>,
2098    #[serde(flatten)]
2099    exit: HarnessExit,
2100}
2101
2102/// A policy hook refused a call before it ran. The one record that says a call the model asked
2103/// for never happened — there is no `tool_call` or `shell` line for a denied call, because
2104/// nothing ran.
2105#[derive(Serialize)]
2106struct CallDeniedEvent {
2107    event_type: &'static str,
2108    event_id: String,
2109    parent_id: Option<String>,
2110    session_id: String,
2111    timestamp: u64,
2112    turn: u32,
2113    /// The gated lifecycle function whose decision point refused: `"on-shell"` or
2114    /// `"on-tool-call"`.
2115    event: String,
2116    /// Manifest name of the policy hook that refused.
2117    hook_name: String,
2118    /// What was refused: the resolved executable path for a shell call, the tool name
2119    /// otherwise.
2120    target: String,
2121    /// The hook's own reason, or the runtime's when the hook supplied none it could use.
2122    reason: String,
2123}
2124
2125/// A tool call was refused before it ran because its input lacked a name the tool's
2126/// `input_schema` lists in `required`. Written in every capture mode: under the default `meta`
2127/// the accompanying `tool_call` record carries no `output`, so this is where the reason lands.
2128#[derive(Serialize)]
2129struct ToolInputRefusedEvent {
2130    event_type: &'static str,
2131    event_id: String,
2132    parent_id: Option<String>,
2133    session_id: String,
2134    timestamp: u64,
2135    turn: u32,
2136    tool_name: String,
2137    /// The provider's id for the call, or `null` where the call arrived without one — every
2138    /// call bridged from a `transport: process` harness.
2139    tool_call_id: Option<String>,
2140    /// The required names the input lacked, in the schema's declared order.
2141    missing: Vec<String>,
2142    /// The refusal text the model was handed.
2143    reason: String,
2144}
2145
2146/// The manifest's own read-only rule refused a call before it ran. Distinct from
2147/// [`CallDeniedEvent`] because it names a different authority and a different subject: no hook was
2148/// asked, and what it carries is the path, the declared rule and the evidence that identified the
2149/// call as a write.
2150#[derive(Serialize)]
2151struct ProtectedPathDeniedEvent {
2152    event_type: &'static str,
2153    event_id: String,
2154    parent_id: Option<String>,
2155    session_id: String,
2156    timestamp: u64,
2157    turn: u32,
2158    /// Which dispatch path was refused: `"shell"` or `"tool"`.
2159    call: String,
2160    /// What was refused: the resolved executable path for a shell call, the tool name otherwise.
2161    target: String,
2162    /// The resolved workdir-relative path, never the string the model typed, so two spellings of
2163    /// one file produce one comparable record.
2164    path: String,
2165    /// The `capabilities.filesystem.read_only` entry that covers `path`, as declared.
2166    rule: String,
2167    /// What identified the call as a write — a redirection operator, a write-verb argument
2168    /// position, or the tool-input key pairing.
2169    signal: String,
2170    /// The same text the model was given, so the trace and the agent agree on why.
2171    reason: String,
2172}
2173
2174#[derive(Serialize)]
2175struct HookDispatchErrorEvent {
2176    event_type: &'static str,
2177    event_id: String,
2178    parent_id: Option<String>,
2179    session_id: String,
2180    timestamp: u64,
2181    hook_name: String,
2182    event: String,
2183    arm: String,
2184}
2185
2186/// A formation member's lifeline read EOF: the formation it belongs to has ended, and this
2187/// session is winding down because of it. Written only when the lifeline is what began the
2188/// session's termination.
2189#[derive(Serialize)]
2190struct FormationEndedEvent {
2191    event_type: &'static str,
2192    event_id: String,
2193    parent_id: Option<String>,
2194    session_id: String,
2195    timestamp: u64,
2196    formation_id: String,
2197}
2198
2199/// One `session-status` call: the object the model was handed, measured at the call.
2200#[derive(Serialize)]
2201struct SessionStatusEvent<'a> {
2202    event_type: &'static str,
2203    event_id: String,
2204    parent_id: Option<String>,
2205    session_id: String,
2206    timestamp: u64,
2207    /// The task that made the call. Absent outside a task.
2208    #[serde(skip_serializing_if = "Option::is_none")]
2209    task_id: Option<String>,
2210    status: &'a crate::session_status::StatusReport,
2211}
2212
2213/// The one deadline notice, appended to the tool result named here.
2214#[derive(Serialize)]
2215struct DeadlineNoticeEvent<'a> {
2216    event_type: &'static str,
2217    event_id: String,
2218    parent_id: Option<String>,
2219    session_id: String,
2220    timestamp: u64,
2221    /// The task whose tool result carried it. Absent outside a task.
2222    #[serde(skip_serializing_if = "Option::is_none")]
2223    task_id: Option<String>,
2224    /// The tool whose result carried it.
2225    tool_name: &'a str,
2226    /// When the runtime ends the session, RFC 3339 UTC.
2227    deadline_at: &'a str,
2228    /// Whole seconds left when the result was handed back.
2229    left_secs: u64,
2230    /// The margin it was due under.
2231    notice_secs: u64,
2232}
2233
2234/// The session reached its deadline, own or inherited, and is winding down because of it. Written
2235/// by the termination routine before any task is cancelled.
2236#[derive(Serialize)]
2237struct DeadlineReachedEvent {
2238    event_type: &'static str,
2239    event_id: String,
2240    parent_id: Option<String>,
2241    session_id: String,
2242    timestamp: u64,
2243    /// When the deadline fell, RFC 3339 UTC.
2244    deadline_at: String,
2245    /// `session_start.deadline.secs`.
2246    deadline_secs: u64,
2247    /// `SessionDeadline::source`: `"own"` or `"inherited"`.
2248    source: &'static str,
2249}
2250
2251/// A delegated child's spawner lifeline read EOF: the process that spawned this session has
2252/// ended, and this session is winding down because of it. Written only when that lifeline is what
2253/// began the session's termination.
2254///
2255/// The lineage keys are the ones `session_start` carries, and like there they are absent rather
2256/// than null for a session launched with a lifeline but no `MURMUR_SPAWNER`.
2257#[derive(Serialize)]
2258struct SpawnerEndedEvent {
2259    event_type: &'static str,
2260    event_id: String,
2261    parent_id: Option<String>,
2262    session_id: String,
2263    timestamp: u64,
2264    #[serde(skip_serializing_if = "Option::is_none")]
2265    spawned_by: Option<String>,
2266    #[serde(skip_serializing_if = "Option::is_none")]
2267    delegation_id: Option<String>,
2268}
2269
2270#[derive(Serialize)]
2271struct ResourceListEvent {
2272    event_type: &'static str,
2273    event_id: String,
2274    parent_id: Option<String>,
2275    session_id: String,
2276    timestamp: u64,
2277    /// `exports.files.root` verbatim, or `""` when the capsule declares no export — a refusal on
2278    /// an undeclared plane is still a complete record of what was asked for.
2279    root: String,
2280    entry_count: usize,
2281    total_bytes: u64,
2282    /// Which completed turn the listing is as of.
2283    generation: u64,
2284    containment_achieved: ContainmentClass,
2285    /// `"ok"`, or the wire error code the request was refused with.
2286    outcome: String,
2287    /// `null` on `ok`, one short sentence otherwise.
2288    reason: Option<String>,
2289}
2290
2291#[derive(Serialize)]
2292struct ResourceReadEvent {
2293    event_type: &'static str,
2294    event_id: String,
2295    parent_id: Option<String>,
2296    session_id: String,
2297    timestamp: u64,
2298    /// The requested path *after* percent-decoding, so `%2e%2e%2f` and `../` read as the same
2299    /// attempt rather than as two unrelated findings.
2300    path: String,
2301    outcome: String,
2302    /// `null` on every non-`ok` outcome.
2303    bytes: Option<u64>,
2304    /// `null` on every non-`ok` outcome.
2305    sha256: Option<String>,
2306    generation: u64,
2307    containment_achieved: ContainmentClass,
2308    reason: Option<String>,
2309}
2310
2311/// One `share-file` call, served or refused, written at the moment of the mint.
2312#[derive(Serialize)]
2313struct PeerHandleMintEvent {
2314    event_type: &'static str,
2315    event_id: String,
2316    parent_id: Option<String>,
2317    session_id: String,
2318    timestamp: u64,
2319    /// `null` on every non-`ok` outcome: a mint that was refused produced no token, so there is
2320    /// no handle to identify.
2321    handle_id: Option<String>,
2322    /// The path relative to `exports.peer_files.root`, canonicalised, or the path the agent asked
2323    /// for when the mint was refused. Never a host path and never the workdir-relative form.
2324    path: String,
2325    audience: String,
2326    /// `null` on every non-`ok` outcome.
2327    expires_at_ms: Option<u64>,
2328    outcome: String,
2329    reason: Option<String>,
2330}
2331
2332/// One redeem against `/resources/peer/<handle>`, written by the listener concurrently with a
2333/// possibly-running task.
2334#[derive(Serialize)]
2335struct PeerHandleRedeemEvent {
2336    event_type: &'static str,
2337    event_id: String,
2338    parent_id: Option<String>,
2339    session_id: String,
2340    timestamp: u64,
2341    handle_id: String,
2342    /// `null` until the MAC has verified. A payload that failed the MAC is caller-controlled and
2343    /// must not be copied into this capsule's own audit record as if it were fact.
2344    path: Option<String>,
2345    /// The runtime's own current counter, never a value taken from the token — so it is always
2346    /// present and always true.
2347    generation: u64,
2348    /// The `x-murmur-audience` header exactly as asserted, or `null` when none was sent.
2349    audience_asserted: Option<String>,
2350    /// `null` on every non-`ok` outcome.
2351    bytes: Option<u64>,
2352    /// `null` on every non-`ok` outcome.
2353    sha256: Option<String>,
2354    outcome: String,
2355    reason: Option<String>,
2356}
2357
2358/// One `fetch-peer-file` call on the ingesting side, served or refused.
2359#[derive(Serialize)]
2360struct PeerFileFetchEvent {
2361    event_type: &'static str,
2362    event_id: String,
2363    parent_id: Option<String>,
2364    session_id: String,
2365    timestamp: u64,
2366    peer: String,
2367    handle_id: String,
2368    /// Where the bytes landed, relative to the accessible workdir. `null` on every non-`ok`
2369    /// outcome.
2370    stored_path: Option<String>,
2371    bytes: Option<u64>,
2372    sha256: Option<String>,
2373    outcome: String,
2374    reason: Option<String>,
2375}
2376
2377/// One launched child, written the moment it is up.
2378///
2379/// Two surfaces launch children — the agent's `delegate-task` tool and a plan's `capsule` step —
2380/// and both write this line, off the same struct and off the same session node. Which one made a
2381/// given delegation is not recorded, because it is not a fact about the delegation.
2382///
2383/// Separate from the `delegation` line below because the two answer different questions and land
2384/// at different times: this one reaches disk while the delegation is still in flight, so a child
2385/// that hangs, crashes or is ended at its deadline is attributable from the parent's side whatever
2386/// happens next. A delegation the daemon refused writes none of these — nothing was launched.
2387#[derive(Serialize)]
2388struct DelegationStartEvent {
2389    event_type: &'static str,
2390    event_id: String,
2391    parent_id: Option<String>,
2392    session_id: String,
2393    timestamp: u64,
2394    /// The `dlg_` id the launcher minted, character-identical to the one on this delegation's
2395    /// terminal `delegation` line and in the child's own `session_start`.
2396    delegation_id: String,
2397    capsule: String,
2398    version: String,
2399    /// The session id the child's runtime minted for itself.
2400    child_session_id: String,
2401    /// The child's directory, relative to this capsule's accessible workdir.
2402    child_workdir: String,
2403    /// The child's records directory, absolute, as the child reported it: its own `trace.jsonl`
2404    /// and `completion.json` are there.
2405    child_records_dir: String,
2406    /// What was copied from this capsule's work folder into the child's before it started, or
2407    /// `null` for a child not listed under `capabilities.spawn.workdir_snapshot`. Always
2408    /// serialized.
2409    workdir_snapshot: Option<crate::workdir_snapshot::WorkdirSnapshot>,
2410}
2411
2412impl DelegationStartEvent {
2413    /// The one place this record is built, so the two appenders that write it cannot drift into
2414    /// two shapes. Which surface launched the child is not an argument, because it is not a field.
2415    #[allow(clippy::too_many_arguments)]
2416    fn new(
2417        session_id: &str,
2418        session_event_id: &str,
2419        delegation_id: &str,
2420        capsule: &str,
2421        version: &str,
2422        child_session_id: &str,
2423        child_workdir: &str,
2424        child_records_dir: &str,
2425        workdir_snapshot: Option<&crate::workdir_snapshot::WorkdirSnapshot>,
2426    ) -> Self {
2427        Self {
2428            event_type: "delegation_start",
2429            event_id: new_event_id(),
2430            parent_id: Some(session_event_id.to_string()),
2431            session_id: session_id.to_string(),
2432            timestamp: timestamp_ms(),
2433            delegation_id: delegation_id.to_string(),
2434            capsule: capsule.to_string(),
2435            version: version.to_string(),
2436            child_session_id: child_session_id.to_string(),
2437            child_workdir: child_workdir.to_string(),
2438            child_records_dir: child_records_dir.to_string(),
2439            workdir_snapshot: workdir_snapshot.cloned(),
2440        }
2441    }
2442}
2443
2444/// A `call-member` call whose callee holds the task, written as the tool call returns — or later,
2445/// by the call's watcher, when a callee that was busy takes the task.
2446///
2447/// Carries the callee's roster name and the id of the task its door holds, never its door's URL
2448/// or the token the call presented.
2449#[derive(Serialize)]
2450struct MemberCallStartEvent {
2451    event_type: &'static str,
2452    event_id: String,
2453    parent_id: Option<String>,
2454    session_id: String,
2455    timestamp: u64,
2456    /// The calling task.
2457    task_id: String,
2458    /// The `mcl_` id the call returned to the model.
2459    call_id: String,
2460    /// The callee's roster name.
2461    member: String,
2462    /// The task the callee's door holds, as its `GetTask` names it.
2463    member_task_id: String,
2464}
2465
2466/// One offer of a `call-member` call that the callee's door turned away busy: it answered
2467/// `SendMessage` with `rejected` and [`crate::a2a::REJECTED_BUSY_MESSAGE`]. The first is written
2468/// as the tool call returns, each later one by the call's watcher as it offers the task again.
2469///
2470/// Carries the callee's roster name, never its door's URL or the token the call presented.
2471#[derive(Serialize)]
2472struct MemberCallBusyEvent {
2473    event_type: &'static str,
2474    event_id: String,
2475    parent_id: Option<String>,
2476    session_id: String,
2477    timestamp: u64,
2478    /// The calling task.
2479    task_id: String,
2480    /// The `mcl_` id the call returned to the model.
2481    call_id: String,
2482    /// The callee's roster name.
2483    member: String,
2484    /// Which offer of the call this was, from 1.
2485    offer: u32,
2486    /// From the tool call that made the call to this refusal.
2487    waited_ms: u64,
2488    /// The door's status message.
2489    message: String,
2490}
2491
2492/// One `call-member` call, written once it is accounted for: delivered to the calling task, or
2493/// left behind when that task ended.
2494///
2495/// A call that never started writes only this, with no `member_task_id`. Carries no URL and no
2496/// token.
2497#[derive(Serialize)]
2498struct MemberCallEvent {
2499    event_type: &'static str,
2500    event_id: String,
2501    parent_id: Option<String>,
2502    session_id: String,
2503    timestamp: u64,
2504    task_id: String,
2505    call_id: String,
2506    member: String,
2507    /// Absent, not null, for a call the callee never held.
2508    #[serde(skip_serializing_if = "Option::is_none")]
2509    member_task_id: Option<String>,
2510    /// `completed`, `failed`, `canceled`, `rejected`, `timed_out`, `unreachable`, `abandoned` or
2511    /// `no_answer`.
2512    status: String,
2513    duration_ms: u64,
2514    /// The callee's answer, its status message, or why the call ended.
2515    output: String,
2516    truncated: bool,
2517    /// Whether the calling task received `output`: as the tool result for a call that never
2518    /// started, as a continuation for one that did.
2519    delivered: bool,
2520    /// The members further down that gave the callee no answer, as its `GetTask` reported them.
2521    /// Absent when it reported none.
2522    #[serde(skip_serializing_if = "Vec::is_empty")]
2523    no_answer_below: Vec<NoAnswerBelow>,
2524}
2525
2526/// One member a callee reported as giving it no answer.
2527#[derive(Serialize)]
2528struct NoAnswerBelow {
2529    member: String,
2530    /// A `member_call` status other than `completed`.
2531    status: String,
2532}
2533
2534/// One delegation, written when it ends.
2535///
2536/// One line per call whatever happened, including a call the daemon refused: a delegation that was
2537/// asked for and not made is as much a fact of the run as one that produced an answer. When it is
2538/// written depends on how far the delegation got — a `delegate-task` call that started a child
2539/// writes it as that child's completion arrives, out of the child's own `completion.json`; one that
2540/// never started a child writes it within the call. A plan `capsule` step waits for its child's
2541/// answer, so it always writes this line within the step. Carries neither the task text nor the
2542/// child's answer — both are the agent's own conversation, which the `tool_call` line for the
2543/// same call already records under the session's `trace.capture` setting.
2544#[derive(Serialize)]
2545struct DelegationEvent {
2546    event_type: &'static str,
2547    event_id: String,
2548    parent_id: Option<String>,
2549    session_id: String,
2550    timestamp: u64,
2551    /// The sub-capsule the agent named, and the exact version it named.
2552    capsule: String,
2553    version: String,
2554    /// The `dlg_` id naming this delegation. `null` on a `refused` outcome, which made none.
2555    delegation_id: Option<String>,
2556    /// The child's own session id, so its trace is findable. `null` when no child ran.
2557    child_session_id: Option<String>,
2558    /// How long the child ran, for a delegation that started; how long the call took, for one that
2559    /// never started.
2560    duration_ms: u64,
2561    /// How the delegation ended. Which vocabulary it is drawn from depends on whether the
2562    /// delegating call waited for the answer itself:
2563    ///
2564    /// - A delegation whose outcome arrived later as a completion reads `ok`, `error`, `crashed`
2565    ///   or `terminated` from [`crate::delegation::DelegationStatus`], or `unknown` when the
2566    ///   completion left no readable file.
2567    /// - One the caller waited out reads `completed` or `timed_out` from
2568    ///   [`crate::delegation_plane::DelegationStatus`].
2569    /// - One that never started reads `failed` or `refused`, from that same enum, either way.
2570    outcome: String,
2571    /// `null` where nothing needs saying. Otherwise the completion's `detail`, or the same
2572    /// sentence the model was given.
2573    reason: Option<String>,
2574}
2575
2576impl DelegationEvent {
2577    /// The one place this record is built, so the two appenders that write it cannot drift into
2578    /// two shapes. Which surface launched the child is not an argument, because it is not a field.
2579    #[allow(clippy::too_many_arguments)]
2580    fn new(
2581        session_id: &str,
2582        session_event_id: &str,
2583        capsule: &str,
2584        version: &str,
2585        delegation_id: Option<String>,
2586        child_session_id: Option<String>,
2587        duration_ms: u64,
2588        outcome: &str,
2589        reason: Option<String>,
2590    ) -> Self {
2591        Self {
2592            event_type: "delegation",
2593            event_id: new_event_id(),
2594            parent_id: Some(session_event_id.to_string()),
2595            session_id: session_id.to_string(),
2596            timestamp: timestamp_ms(),
2597            capsule: capsule.to_string(),
2598            version: version.to_string(),
2599            delegation_id,
2600            child_session_id,
2601            duration_ms,
2602            outcome: outcome.to_string(),
2603            reason,
2604        }
2605    }
2606}
2607
2608// ── TraceWriter impl ─────────────────────────────────────────────────────────
2609
2610impl TraceWriter {
2611    #[allow(clippy::too_many_arguments)]
2612    pub(crate) async fn open(
2613        records_root: &WorkdirRoot,
2614        session_id: String,
2615        capsule_name: String,
2616        capsule_version: String,
2617        model: String,
2618        capabilities: Vec<String>,
2619        effective_grants: ScopeReport,
2620        capture: TraceCapture,
2621        system_prompt: Option<String>,
2622        system_prompt_overridden: bool,
2623        resumed_from: Option<String>,
2624        context_id: Option<String>,
2625        spawned_by: Option<String>,
2626        spawn_delegation_id: Option<String>,
2627    ) -> std::io::Result<Self> {
2628        let chain = crate::record_seal::InlineChain::open(records_root, TRACE_FILE_NAME)?;
2629        // `"cli"` wins over `"none"` when the override cleared the prompt: the operator passed
2630        // `--system-prompt ""`, which is a decision worth recording, not an absence.
2631        let system_prompt_source = match (system_prompt_overridden, system_prompt.is_some()) {
2632            (true, _) => "cli",
2633            (false, true) => "manifest",
2634            (false, false) => "none",
2635        };
2636        let system_prompt_sha256 = system_prompt
2637            .as_ref()
2638            .map(|prompt| murmur_artifact::sha256_hex(prompt.as_bytes()));
2639        let blobs = BlobStore::new(records_root);
2640        // The prompt the operator wrote, kept under the hash `session_start` already carried.
2641        // Written here rather than at `session_start` because a session writes that frame once
2642        // per task and the blob is the same one every time.
2643        if let (true, Some(prompt), Some(sha)) = (
2644            capture.captures_content(),
2645            system_prompt.as_ref(),
2646            system_prompt_sha256.as_deref(),
2647        ) {
2648            blobs.put(sha, prompt.as_bytes()).await?;
2649        }
2650        Ok(Self {
2651            chain,
2652            records_root: records_root.clone(),
2653            sealed_head: None,
2654            session_id,
2655            capsule_name,
2656            capsule_version,
2657            model,
2658            capabilities,
2659            effective_grants,
2660            capture,
2661            blobs,
2662            system_prompt_source,
2663            system_prompt_sha256,
2664            credential_source: "none",
2665            gateways: Vec::new(),
2666            control: None,
2667            inference_choices: Vec::new(),
2668            tool_refresh: None,
2669            runtime_artifacts: Vec::new(),
2670            host_facts: Vec::new(),
2671            instruction_files: None,
2672            resumed_from,
2673            context_id,
2674            spawned_by,
2675            spawn_delegation_id,
2676            formation_id: None,
2677            formation_member: None,
2678            session_start_time: Instant::now(),
2679            session_event_id: new_event_id(),
2680            session_started: false,
2681            session_ended: false,
2682            task_event_id: None,
2683            turn_event_id: None,
2684            total_turns: 0,
2685            total_input_tokens: 0,
2686            total_output_tokens: 0,
2687            total_tool_calls: 0,
2688            total_shell_calls: 0,
2689            task_turns: 0,
2690            task_input_tokens: 0,
2691            task_output_tokens: 0,
2692            task_tool_calls: 0,
2693            task_shell_calls: 0,
2694            task_agent_turns: 0,
2695            task_hook_inferences: 0,
2696            task_capped_turns: 0,
2697            task_start_instant: None,
2698            active_task_id: None,
2699            task_failures_written: 0,
2700            last_task_failure: None,
2701            max_session_tokens: None,
2702            machine_tokens_per_day: None,
2703            session_status: None,
2704            call_deadlines: None,
2705            member_requests: None,
2706        })
2707    }
2708
2709    /// The session's [`crate::limits::CallDeadlines`], which this writer drains into one
2710    /// `call_deadline` line per firing ahead of each line it writes, `session_end` included.
2711    pub(crate) fn set_call_deadlines(&mut self, deadlines: crate::limits::CallDeadlines) {
2712        self.call_deadlines = Some(deadlines);
2713    }
2714
2715    /// The session's [`crate::member_request::MemberRequests`], which this writer drains into one
2716    /// `member_request` line per request wherever it drains [`Self::set_call_deadlines`]' latch.
2717    pub(crate) fn set_member_requests(&mut self, requests: crate::member_request::MemberRequests) {
2718        self.member_requests = Some(requests);
2719    }
2720
2721    /// The session's standing, which every later `inference`, `task_start` and `task_end` write
2722    /// feeds and whose deadline every later `session_start` records.
2723    pub(crate) fn set_session_status(
2724        &mut self,
2725        status: std::sync::Arc<crate::session_status::SessionStatus>,
2726    ) {
2727        self.session_status = Some(status);
2728    }
2729
2730    /// The spend ceilings this session runs under, written into every later `session_start`.
2731    pub(crate) fn set_spend_ceilings(
2732        &mut self,
2733        max_session_tokens: Option<u64>,
2734        machine_tokens_per_day: Option<u64>,
2735    ) {
2736        self.max_session_tokens = max_session_tokens;
2737        self.machine_tokens_per_day = machine_tokens_per_day;
2738    }
2739
2740    /// The session node of this writer's event tree — the `event_id` its `session_start` will
2741    /// carry, available before that line is written so [`ResourceTraceAppender`] can be opened
2742    /// against the same node.
2743    pub(crate) fn session_event_id(&self) -> &str {
2744        &self.session_event_id
2745    }
2746
2747    /// Whether this session's `inference` records carry content hashes — everything but
2748    /// `trace.capture: none`. The agent loop asks before splitting a payload into a
2749    /// [`WireCapture`], so `none` costs no serialization at all.
2750    pub(crate) fn captures_wire(&self) -> bool {
2751        self.capture.captures_hashes()
2752    }
2753
2754    /// Parent for a launch-scoped event: the session node, or `None` on a writer that has not
2755    /// written `session_start`. A trace line must never name a parent that has no line behind
2756    /// it.
2757    fn session_parent(&self) -> Option<String> {
2758        self.session_started.then(|| self.session_event_id.clone())
2759    }
2760
2761    /// Parent for a task-scoped event: the task node, falling back to the session node when no
2762    /// task is active.
2763    fn task_parent(&self) -> Option<String> {
2764        self.task_event_id.clone().or_else(|| self.session_parent())
2765    }
2766
2767    /// Parent for a turn-scoped event: the turn node, falling back to the task node and then to
2768    /// the session node. The fallbacks are what keeps a tool call written outside any turn — a
2769    /// hook's, or the process transport's, before its first `inference` — attached to the tree.
2770    fn turn_parent(&self) -> Option<String> {
2771        self.turn_event_id.clone().or_else(|| self.task_parent())
2772    }
2773
2774    /// Records where the inference credential came from, as
2775    /// [`crate::gateway_credential::CredentialSource::trace_name`] names it. `"none"` until set.
2776    pub(crate) fn set_credential_source(&mut self, source: &'static str) {
2777        self.credential_source = source;
2778    }
2779
2780    /// Records the session's credential gateways for `session_start.gateways`.
2781    pub(crate) fn set_gateways(&mut self, gateways: Vec<SessionGateway>) {
2782        self.gateways = gateways;
2783    }
2784
2785    /// Records what `control:` declares for `session_start.control`.
2786    pub(crate) fn set_control(&mut self, control: Option<SessionControl>) {
2787        self.control = control;
2788    }
2789
2790    /// Records the driver choices for `session_start.inference_choices`. Left empty, the key is
2791    /// not written.
2792    pub(crate) fn set_inference_choices(&mut self, choices: Vec<SessionInferenceChoice>) {
2793        self.inference_choices = choices;
2794    }
2795
2796    /// Records the runtime-origin entries of `installed` for `session_start.runtime_artifacts`,
2797    /// in `installed`'s order. Operator-declared entries are left out.
2798    pub(crate) fn set_runtime_artifacts(&mut self, installed: &[InstalledArtifactSummary]) {
2799        self.runtime_artifacts = installed
2800            .iter()
2801            .filter_map(|artifact| match &artifact.origin {
2802                LockOrigin::Runtime { session } => Some(RuntimeArtifact {
2803                    name: artifact.name.clone(),
2804                    version: artifact.version.clone(),
2805                    origin: artifact.origin.as_str(),
2806                    session: session.clone(),
2807                    trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
2808                }),
2809                LockOrigin::Operator => None,
2810            })
2811            .collect();
2812    }
2813
2814    /// Records `uses`, in order, for `session_start.host_facts`.
2815    pub(crate) fn set_host_facts(&mut self, uses: &[crate::host_facts::HostFactsUse]) {
2816        self.host_facts = uses.iter().map(HostFactsRecord::of).collect();
2817    }
2818
2819    /// Records that this inference call is the first to use a setting value a controller set:
2820    /// `turn` is the call's zero-based turn, the same number its `inference` line carries, and
2821    /// `change_id` the `event_id` of the `control_change` that set `value`.
2822    ///
2823    /// Written before the call is sent, so it precedes the turn's `inference` line and hangs off
2824    /// the task.
2825    pub(crate) async fn write_control_applied(
2826        &mut self,
2827        turn: u32,
2828        name: &str,
2829        value: Value,
2830        change_id: &str,
2831    ) -> std::io::Result<()> {
2832        let event = ControlAppliedEvent {
2833            event_type: "control_applied",
2834            event_id: new_event_id(),
2835            parent_id: self.task_parent(),
2836            session_id: self.session_id.clone(),
2837            timestamp: timestamp_ms(),
2838            turn,
2839            task_id: self.active_task_id.clone(),
2840            name,
2841            value,
2842            change_id,
2843        };
2844        self.write_event(&event).await
2845    }
2846
2847    /// Records the formation this session is a member of for `session_start.formation_id`.
2848    pub(crate) fn set_formation_id(&mut self, formation_id: Option<&FormationId>) {
2849        self.formation_id = formation_id.cloned();
2850    }
2851
2852    /// Records `session_start.formation_member` and `session_start.formation_callees` for a
2853    /// session its formation launcher handed credentials to.
2854    pub(crate) fn set_formation_member(
2855        &mut self,
2856        member: Option<&crate::formation_credentials::FormationMember>,
2857    ) {
2858        self.formation_member = member.map(|member| {
2859            (
2860                member.name().to_string(),
2861                member.callees().map(str::to_string).collect(),
2862            )
2863        });
2864    }
2865
2866    /// Records what staging read for `inference.instruction_files` for
2867    /// `session_start.instruction_files`; `None` writes `null`.
2868    pub(crate) fn set_instruction_files(
2869        &mut self,
2870        files: Option<&crate::instruction_files::InstructionFiles>,
2871    ) {
2872        self.instruction_files =
2873            files.map(crate::instruction_files::InstructionFiles::trace_records);
2874    }
2875
2876    /// Records `inference.tool_refresh` for `session_start.tool_refresh`: the wire name under
2877    /// `transport: http`, `None` under `transport: process`.
2878    pub(crate) fn set_tool_refresh(&mut self, tool_refresh: Option<&'static str>) {
2879        self.tool_refresh = tool_refresh;
2880    }
2881
2882    /// Records that the tool array the http agent loop sends changed before this call:
2883    /// `trigger` is why (`"compaction"`, `"agent"` or `"immediate"`), `added` and `removed` the
2884    /// sorted names that entered and left it, and `tools` every name it now offers, in array
2885    /// order.
2886    ///
2887    /// Written before the call is sent, so it precedes the turn's `inference` line and hangs off
2888    /// the task, as `control_applied` does.
2889    pub(crate) async fn write_tools_refreshed(
2890        &mut self,
2891        turn: u32,
2892        trigger: &str,
2893        added: &[String],
2894        removed: &[String],
2895        tools: &[String],
2896    ) -> std::io::Result<()> {
2897        let event = ToolsRefreshedEvent {
2898            event_type: "tools_refreshed",
2899            event_id: new_event_id(),
2900            parent_id: self.task_parent(),
2901            session_id: self.session_id.clone(),
2902            timestamp: timestamp_ms(),
2903            turn,
2904            task_id: self.active_task_id.clone(),
2905            trigger,
2906            added,
2907            removed,
2908            tools,
2909        };
2910        self.write_event(&event).await
2911    }
2912
2913    pub(crate) async fn write_session_start(
2914        &mut self,
2915        max_turns: Option<u32>,
2916        model_limits: Option<crate::model_limits::ResolvedModelLimits>,
2917        tools_declared: Vec<String>,
2918    ) -> std::io::Result<()> {
2919        let event = SessionStartEvent {
2920            event_type: "session_start",
2921            event_id: self.session_event_id.clone(),
2922            parent_id: None,
2923            session_id: self.session_id.clone(),
2924            timestamp: timestamp_ms(),
2925            capsule_name: self.capsule_name.clone(),
2926            capsule_version: self.capsule_version.clone(),
2927            model: self.model.clone(),
2928            max_turns,
2929            model_limits,
2930            max_session_tokens: self.max_session_tokens,
2931            machine_tokens_per_day: self.machine_tokens_per_day,
2932            deadline: self.session_status.as_ref().and_then(|status| {
2933                status.deadline().map(|deadline| SessionStartDeadline {
2934                    at: deadline.at_rfc3339(),
2935                    secs: deadline.secs(),
2936                    notice_secs: status.notice_secs(),
2937                    source: deadline.source().as_str(),
2938                })
2939            }),
2940            capabilities: self.capabilities.clone(),
2941            tools_declared,
2942            containment_declared: self.effective_grants.declared_containment,
2943            containment_achieved: self.effective_grants.achieved_containment,
2944            workdir_exec: self.effective_grants.workdir_exec,
2945            userns_grant: self.effective_grants.userns_grant,
2946            effective_grants: self.effective_grants.clone(),
2947            system_prompt_source: self.system_prompt_source,
2948            system_prompt_sha256: self.system_prompt_sha256.clone(),
2949            credential_source: self.credential_source,
2950            gateways: self.gateways.clone(),
2951            control: self.control.clone(),
2952            inference_choices: self.inference_choices.clone(),
2953            tool_refresh: self.tool_refresh,
2954            runtime_artifacts: self.runtime_artifacts.clone(),
2955            host_facts: self.host_facts.clone(),
2956            instruction_files: self.instruction_files.clone(),
2957            resumed_from: self.resumed_from.clone(),
2958            context_id: self.context_id.clone(),
2959            spawned_by: self.spawned_by.clone(),
2960            delegation_id: self.spawn_delegation_id.clone(),
2961            formation_id: self.formation_id.clone(),
2962            formation_member: self.formation_member.as_ref().map(|(name, _)| name.clone()),
2963            formation_callees: self
2964                .formation_member
2965                .as_ref()
2966                .map(|(_, callees)| callees.clone()),
2967        };
2968        self.write_event(&event).await?;
2969        self.session_started = true;
2970        // A firing from before the session frame existed — an `on-stage` hook's — hangs off it.
2971        self.write_latched().await
2972    }
2973
2974    /// `input_tokens`/`output_tokens` are what the session and task totals accumulate: the
2975    /// runtime's own tiktoken estimates under `transport: http`, and the counts the harness's
2976    /// driver reported under `transport: process`, where the runtime measures nothing itself.
2977    /// `None` is a turn nothing counted, which only a process driver reporting no usage
2978    /// produces, and it adds nothing to the totals.
2979    ///
2980    /// `usage` is written verbatim beside them and accumulated into nothing. Under `http` it is
2981    /// the provider's own report for the same call; under `process` it carries only the cache and
2982    /// thinking counts, because the harness's input and output are already the two above.
2983    ///
2984    /// `settings` is how an `http` call was sent, written as `max_output_tokens` and
2985    /// `driver_config`; `None` under `process`, which writes neither key.
2986    #[allow(clippy::too_many_arguments)]
2987    pub(crate) async fn write_inference(
2988        &mut self,
2989        turn: u32,
2990        input_tokens: Option<u64>,
2991        output_tokens: Option<u64>,
2992        decision: String,
2993        stop_reason: Option<&str>,
2994        tool_name: Option<String>,
2995        origin: Option<&InferenceOrigin>,
2996        usage: Option<&DriverUsage>,
2997        message_ids: Vec<String>,
2998        wire: Option<&WireCapture>,
2999        settings: Option<&InferenceCallSettings>,
3000    ) -> std::io::Result<()> {
3001        self.write_inference_event(InferenceLine {
3002            turn,
3003            input_tokens,
3004            output_tokens,
3005            decision,
3006            stop_reason,
3007            tool_name,
3008            origin,
3009            usage,
3010            message_ids,
3011            wire,
3012            choice: None,
3013            failure: None,
3014            counted: true,
3015            settings,
3016            retry: RetryNote::default(),
3017            gateway: None,
3018        })
3019        .await
3020    }
3021
3022    /// [`Self::write_inference`] for a hook's `run-inference` that succeeded, with the attempts
3023    /// its retry loop took.
3024    #[allow(clippy::too_many_arguments)]
3025    pub(crate) async fn write_hook_inference(
3026        &mut self,
3027        turn: u32,
3028        input_tokens: Option<u64>,
3029        output_tokens: Option<u64>,
3030        decision: String,
3031        origin: &InferenceOrigin,
3032        usage: Option<&DriverUsage>,
3033        settings: Option<&InferenceCallSettings>,
3034        retry: RetryNote,
3035        gateway: Option<GatewayOffsets>,
3036    ) -> std::io::Result<()> {
3037        self.write_inference_event(InferenceLine {
3038            turn,
3039            input_tokens,
3040            output_tokens,
3041            decision,
3042            stop_reason: None,
3043            tool_name: None,
3044            origin: Some(origin),
3045            usage,
3046            message_ids: Vec::new(),
3047            wire: None,
3048            choice: None,
3049            failure: None,
3050            counted: true,
3051            settings,
3052            retry,
3053            gateway,
3054        })
3055        .await
3056    }
3057
3058    /// [`Self::write_inference`] for an agent-loop turn, naming the driver choice that served it
3059    /// as `driver_choice` and `model`. `choice` is `None` on a capsule with no alternates, whose
3060    /// line is exactly what [`Self::write_inference`] writes.
3061    #[allow(clippy::too_many_arguments)]
3062    pub(crate) async fn write_turn_inference(
3063        &mut self,
3064        turn: u32,
3065        input_tokens: Option<u64>,
3066        output_tokens: Option<u64>,
3067        decision: String,
3068        stop_reason: Option<&str>,
3069        tool_name: Option<String>,
3070        usage: Option<&DriverUsage>,
3071        message_ids: Vec<String>,
3072        wire: Option<&WireCapture>,
3073        choice: Option<&crate::driver_choice::DriverChoice>,
3074        settings: Option<&InferenceCallSettings>,
3075        retry: RetryNote,
3076        gateway: Option<GatewayOffsets>,
3077    ) -> std::io::Result<()> {
3078        if stop_reason == Some("max_tokens") {
3079            self.task_capped_turns = self.task_capped_turns.saturating_add(1);
3080        }
3081        self.write_inference_event(InferenceLine {
3082            turn,
3083            input_tokens,
3084            output_tokens,
3085            decision,
3086            stop_reason,
3087            tool_name,
3088            origin: None,
3089            usage,
3090            message_ids,
3091            wire,
3092            choice,
3093            failure: None,
3094            counted: true,
3095            settings,
3096            retry,
3097            gateway,
3098        })
3099        .await
3100    }
3101
3102    /// Record a model call that failed: `decision` and `stop_reason` `"error"`, the failure's
3103    /// `error_code`, `error` (capped at [`MAX_TASK_FAILURE_REASON_BYTES`]) and `provider_status`.
3104    ///
3105    /// Never hashes or stores a wire body, whatever `trace.capture` is. Writes `input_tokens` and
3106    /// `output_tokens` only from `failed.spent`, and advances the turn and token counters only
3107    /// when `spent` is `Some`, by exactly its counts. Parents like any other `inference`: an
3108    /// agent-loop record becomes the turn node, a hook's hangs off the turn.
3109    pub(crate) async fn write_failed_inference(
3110        &mut self,
3111        failed: FailedInference<'_>,
3112    ) -> std::io::Result<()> {
3113        let error =
3114            crate::agent::truncate_on_char_boundary(failed.error, MAX_TASK_FAILURE_REASON_BYTES)
3115                .to_string();
3116        let (input_tokens, output_tokens) = failed.spent.unwrap_or((None, None));
3117        self.write_inference_event(InferenceLine {
3118            turn: failed.turn,
3119            input_tokens,
3120            output_tokens,
3121            decision: "error".to_string(),
3122            stop_reason: Some("error"),
3123            tool_name: failed.tool_name,
3124            origin: failed.origin,
3125            usage: failed.usage,
3126            message_ids: failed.message_ids,
3127            wire: None,
3128            choice: failed.choice,
3129            failure: Some((failed.error_code, error, failed.provider_status)),
3130            counted: failed.spent.is_some(),
3131            settings: failed.settings,
3132            retry: failed.retry,
3133            gateway: failed.gateway,
3134        })
3135        .await
3136    }
3137
3138    async fn write_inference_event(&mut self, line: InferenceLine<'_>) -> std::io::Result<()> {
3139        let InferenceLine {
3140            turn,
3141            input_tokens,
3142            output_tokens,
3143            decision,
3144            stop_reason,
3145            tool_name,
3146            origin,
3147            usage,
3148            message_ids,
3149            wire,
3150            choice,
3151            failure,
3152            counted,
3153            settings,
3154            retry,
3155            gateway,
3156        } = line;
3157        let (error_code, error, provider_status) = match failure {
3158            Some((code, error, status)) => (Some(code.to_string()), Some(error), status),
3159            None => (None, None, None),
3160        };
3161        // Drained under the previous turn, before this line becomes the turn node.
3162        self.write_latched().await?;
3163        let event_id = new_event_id();
3164        // The agent loop's own inference *is* the turn node — there is no separate turn line —
3165        // so it hangs off the task and everything the turn goes on to produce hangs off it. A
3166        // hook's `run-inference` (`origin: Some(_)`) is one of those products, not a new turn.
3167        let parent_id = if origin.is_none() {
3168            let parent = self.task_parent();
3169            self.turn_event_id = Some(event_id.clone());
3170            parent
3171        } else {
3172            self.turn_parent()
3173        };
3174        let (system_sha, tools_sha, response_sha, message_shas) =
3175            match wire.filter(|_| self.capture.captures_hashes()) {
3176                Some(wire) => self.record_wire(wire).await?,
3177                None => (None, None, None, Vec::new()),
3178            };
3179        let event = InferenceEvent {
3180            event_type: "inference",
3181            event_id,
3182            parent_id,
3183            session_id: self.session_id.clone(),
3184            timestamp: timestamp_ms(),
3185            turn,
3186            task_id: self.active_task_id.clone(),
3187            input_tokens,
3188            output_tokens,
3189            decision,
3190            stop_reason: stop_reason.map(str::to_string),
3191            tool_name,
3192            input_tokens_actual: usage.and_then(|u| u.input_tokens),
3193            output_tokens_actual: usage.and_then(|u| u.output_tokens),
3194            cached_tokens: usage.and_then(|u| u.cached_tokens),
3195            cache_write_tokens: usage.and_then(|u| u.cache_write_tokens),
3196            thinking_tokens: usage.and_then(|u| u.thinking_tokens),
3197            origin: origin.map(|o| o.source.clone()),
3198            driver_choice: choice.map(|choice| choice.name.clone()),
3199            model: origin
3200                .map(|o| o.model.clone())
3201                .or_else(|| choice.map(|choice| choice.model.clone())),
3202            max_output_tokens: settings.map(|settings| settings.max_output_tokens),
3203            input_budget: settings.map(|settings| settings.input_budget),
3204            driver_config: settings.map(|settings| settings.driver_config.clone()),
3205            message_ids,
3206            system_sha,
3207            tools_sha,
3208            response_sha,
3209            message_shas,
3210            error_code,
3211            error,
3212            provider_status,
3213            gateway_reached_ms: gateway.map(|gateway| gateway.reached_ms),
3214            upstream_responded_ms: gateway.map(|gateway| gateway.responded_ms),
3215            attempts: retry.attempts,
3216            retry_stopped: retry.stopped.map(RetryStop::wire_name),
3217        };
3218        self.write_event(&event).await?;
3219        if origin.is_none() {
3220            self.task_agent_turns = self.task_agent_turns.saturating_add(1);
3221        }
3222        if counted {
3223            // A turn nothing counted contributes nothing to the totals. The totals are plain
3224            // `u64` because a session's spend is a sum, and the sum of no measurements is zero;
3225            // the absent/zero distinction lives on the record itself.
3226            let (input, output) = (input_tokens.unwrap_or(0), output_tokens.unwrap_or(0));
3227            self.total_turns = self.total_turns.saturating_add(1);
3228            self.total_input_tokens = self.total_input_tokens.saturating_add(input);
3229            self.total_output_tokens = self.total_output_tokens.saturating_add(output);
3230            self.task_turns = self.task_turns.saturating_add(1);
3231            if origin.is_some() {
3232                self.task_hook_inferences = self.task_hook_inferences.saturating_add(1);
3233            }
3234            self.task_input_tokens = self.task_input_tokens.saturating_add(input);
3235            self.task_output_tokens = self.task_output_tokens.saturating_add(output);
3236        }
3237        // Every record, counted or not: the provider may have reported a call the turn and token
3238        // totals do not count.
3239        if let Some(status) = &self.session_status {
3240            status.record_inference(self.task_turns, input_tokens, output_tokens, usage);
3241        }
3242        Ok(())
3243    }
3244
3245    /// Hash the four bodies of one request and, under [`TraceCapture::Content`], store each
3246    /// behind its hash.
3247    ///
3248    /// The hash is computed in every capture mode that reaches here; only the blob write is
3249    /// gated, so `meta` and `content` name the same bytes and differ solely in whether those
3250    /// bytes are also on disk.
3251    async fn record_wire(
3252        &mut self,
3253        wire: &WireCapture,
3254    ) -> std::io::Result<(Option<String>, Option<String>, Option<String>, Vec<String>)> {
3255        let system_sha = murmur_artifact::sha256_hex(&wire.system);
3256        let tools_sha = murmur_artifact::sha256_hex(&wire.tools);
3257        let response_sha = murmur_artifact::sha256_hex(&wire.response);
3258        let message_shas: Vec<String> = wire
3259            .messages
3260            .iter()
3261            .map(|message| murmur_artifact::sha256_hex(message))
3262            .collect();
3263
3264        if self.capture.captures_content() {
3265            // Verbatim, unredacted. `write_tool_call` redacts peer handles out of its own summary
3266            // fields; a blob is not a summary, and redacting one would make its hash name bytes
3267            // no file holds — destroying the only property this store exists to provide.
3268            self.blobs.put(&system_sha, &wire.system).await?;
3269            self.blobs.put(&tools_sha, &wire.tools).await?;
3270            self.blobs.put(&response_sha, &wire.response).await?;
3271            for (sha, body) in message_shas.iter().zip(&wire.messages) {
3272                self.blobs.put(sha, body).await?;
3273            }
3274        }
3275
3276        Ok((
3277            Some(system_sha),
3278            Some(tools_sha),
3279            Some(response_sha),
3280            message_shas,
3281        ))
3282    }
3283
3284    #[allow(clippy::too_many_arguments)]
3285    pub(crate) async fn write_tool_call(
3286        &mut self,
3287        turn: u32,
3288        tool_name: String,
3289        tool_call_id: Option<String>,
3290        input: Value,
3291        input_bytes: u64,
3292        output: &str,
3293        output_bytes: u64,
3294        duration_ms: u64,
3295        status: String,
3296        state_effect: Option<String>,
3297        resource_id: Option<String>,
3298        error: Option<&str>,
3299    ) -> std::io::Result<()> {
3300        self.write_tool_call_with_attachments(
3301            turn,
3302            tool_name,
3303            tool_call_id,
3304            input,
3305            input_bytes,
3306            output,
3307            output_bytes,
3308            duration_ms,
3309            status,
3310            state_effect,
3311            resource_id,
3312            error,
3313            Vec::new(),
3314        )
3315        .await
3316    }
3317
3318    /// [`Self::write_tool_call`] for a call that returned images: `attachments` becomes the
3319    /// record's `attachments` field, which an empty list omits.
3320    #[allow(clippy::too_many_arguments)]
3321    pub(crate) async fn write_tool_call_with_attachments(
3322        &mut self,
3323        turn: u32,
3324        tool_name: String,
3325        tool_call_id: Option<String>,
3326        mut input: Value,
3327        input_bytes: u64,
3328        output: &str,
3329        output_bytes: u64,
3330        duration_ms: u64,
3331        status: String,
3332        state_effect: Option<String>,
3333        resource_id: Option<String>,
3334        error: Option<&str>,
3335        attachments: Vec<AttachmentRecord>,
3336    ) -> std::io::Result<()> {
3337        // A peer handle reaches this event by an ordinary route — it is an argument the model
3338        // passed to `fetch-peer-file`, and every call's arguments are recorded. It is also a
3339        // credential, and the trace is durable, so the two must not meet: the `handle_id` goes in
3340        // instead, which is what correlates the record with the mint and the redeem anyway.
3341        crate::peer_handoff::redact_handles_in_json(&mut input);
3342        let event = ToolCallEvent {
3343            event_type: "tool_call",
3344            event_id: new_event_id(),
3345            parent_id: self.turn_parent(),
3346            session_id: self.session_id.clone(),
3347            timestamp: timestamp_ms(),
3348            turn,
3349            task_id: self.active_task_id.clone(),
3350            tool_name,
3351            // An absent or empty provider id records as `null`: `""` would read as an id the
3352            // provider actually issued.
3353            tool_call_id: tool_call_id.filter(|id| !id.is_empty()),
3354            input,
3355            input_bytes,
3356            output: self
3357                .capture
3358                .captures_content()
3359                .then(|| crate::peer_handoff::redact_handle_tokens(output).into_owned()),
3360            output_bytes,
3361            duration_ms,
3362            status,
3363            error: error.map(|error| {
3364                let redacted = crate::peer_handoff::redact_handle_tokens(error);
3365                crate::agent::truncate_on_char_boundary(&redacted, MAX_TASK_FAILURE_REASON_BYTES)
3366                    .to_string()
3367            }),
3368            state_effect,
3369            resource_id,
3370            attachments,
3371        };
3372        self.write_event(&event).await?;
3373        self.total_tool_calls = self.total_tool_calls.saturating_add(1);
3374        self.task_tool_calls = self.task_tool_calls.saturating_add(1);
3375        Ok(())
3376    }
3377
3378    /// Records a skill invocation as a `skill_call` event. Skill calls are NOT counted in
3379    /// `total_tool_calls` — they appear in a separate `── Skill calls ──` section in `mur trace show`.
3380    ///
3381    /// `origin` is the skill's `murmur.lock` origin; the record's `trust` is derived from it
3382    /// through [`crate::origin::artifact_trust`], the same rule that decides the skill's fence.
3383    pub(crate) async fn write_skill_call(
3384        &mut self,
3385        turn: u32,
3386        skill_name: String,
3387        origin: &LockOrigin,
3388        output_bytes: u64,
3389        duration_ms: u64,
3390        status: String,
3391    ) -> std::io::Result<()> {
3392        let event = SkillCallEvent {
3393            event_type: "skill_call",
3394            event_id: new_event_id(),
3395            parent_id: self.turn_parent(),
3396            session_id: self.session_id.clone(),
3397            timestamp: timestamp_ms(),
3398            turn,
3399            task_id: self.active_task_id.clone(),
3400            skill_name,
3401            output_bytes,
3402            duration_ms,
3403            status,
3404            origin: origin.as_str(),
3405            trust: crate::origin::artifact_trust(origin).as_str(),
3406        };
3407        self.write_event(&event).await
3408        // Intentionally does not increment total_tool_calls or task_tool_calls.
3409    }
3410
3411    // One positional parameter per JSONL column, as every other `write_*` here does; the
3412    // sibling `write_tool_call` carries the same allowance.
3413    #[allow(clippy::too_many_arguments)]
3414    pub(crate) async fn write_shell(
3415        &mut self,
3416        turn: u32,
3417        binary: String,
3418        command: String,
3419        exit_code: i32,
3420        stdout_bytes: u64,
3421        stderr_bytes: u64,
3422        duration_ms: u64,
3423        resource_limit: Option<String>,
3424    ) -> std::io::Result<()> {
3425        let event = ShellEvent {
3426            event_type: "shell",
3427            event_id: new_event_id(),
3428            parent_id: self.turn_parent(),
3429            session_id: self.session_id.clone(),
3430            timestamp: timestamp_ms(),
3431            turn,
3432            task_id: self.active_task_id.clone(),
3433            binary,
3434            command,
3435            exit_code,
3436            stdout_bytes,
3437            stderr_bytes,
3438            duration_ms,
3439            resource_limit,
3440        };
3441        self.write_event(&event).await?;
3442        self.total_shell_calls = self.total_shell_calls.saturating_add(1);
3443        self.task_shell_calls = self.task_shell_calls.saturating_add(1);
3444        Ok(())
3445    }
3446
3447    /// A command demoted to the background, written from the agent loop at the moment of
3448    /// demotion.
3449    ///
3450    /// Counts as this session's shell call, exactly as a foreground one does — the pair with
3451    /// [`Self::write_shell_completed`], which deliberately does not count, so `mur trace show`
3452    /// reports each shell command once whichever way it ran.
3453    pub(crate) async fn write_shell_detached(
3454        &mut self,
3455        turn: u32,
3456        work_id: &str,
3457        binary: &str,
3458        command: &str,
3459        grace_ms: Option<u64>,
3460    ) -> std::io::Result<()> {
3461        let event = ShellDetachedEvent {
3462            event_type: "shell_detached",
3463            event_id: new_event_id(),
3464            parent_id: self.turn_parent(),
3465            session_id: self.session_id.clone(),
3466            timestamp: timestamp_ms(),
3467            turn,
3468            task_id: self.active_task_id.clone(),
3469            work_id: work_id.to_string(),
3470            binary: binary.to_string(),
3471            command: command.to_string(),
3472            grace_ms,
3473        };
3474        self.write_event(&event).await?;
3475        self.total_shell_calls = self.total_shell_calls.saturating_add(1);
3476        self.task_shell_calls = self.task_shell_calls.saturating_add(1);
3477        Ok(())
3478    }
3479
3480    /// A detached command's completion queued as the `completion`-origin task
3481    /// `completion_task_id`, written by the task loop as it enqueues it, so the two records join.
3482    pub(crate) async fn write_shell_completed(
3483        &mut self,
3484        completion: &crate::detached::DetachedCompletion,
3485        completion_task_id: &str,
3486    ) -> std::io::Result<()> {
3487        let event = ShellCompletedEvent::new(
3488            self.session_id.clone(),
3489            self.session_parent(),
3490            completion,
3491            crate::detached::Delivery::Task,
3492            Some(completion_task_id),
3493        );
3494        self.write_event(&event).await
3495    }
3496
3497    /// A detached command the session ended without waiting for.
3498    ///
3499    /// `recovered` carries the exit code, the output path and that file's byte count for a command
3500    /// that finished during teardown, and is `None` for one still running. The three travel as one
3501    /// argument because they exist together: a command with an exit code has a written log, and
3502    /// one without has neither.
3503    pub(crate) async fn write_shell_abandoned(
3504        &mut self,
3505        work_id: &str,
3506        binary: &str,
3507        command: &str,
3508        running_ms: u64,
3509        recovered: Option<(i32, &str, u64)>,
3510    ) -> std::io::Result<()> {
3511        let event = ShellAbandonedEvent {
3512            event_type: "shell_abandoned",
3513            event_id: new_event_id(),
3514            parent_id: self.session_parent(),
3515            session_id: self.session_id.clone(),
3516            timestamp: timestamp_ms(),
3517            work_id: work_id.to_string(),
3518            binary: binary.to_string(),
3519            command: command.to_string(),
3520            running_ms,
3521            exit_code: recovered.map(|(exit_code, _, _)| exit_code),
3522            output_path: recovered.map(|(_, path, _)| path.to_string()),
3523            output_bytes: recovered.map(|(_, _, bytes)| bytes),
3524        };
3525        self.write_event(&event).await
3526    }
3527
3528    /// A demotion the trace could not record, written where the failure was seen.
3529    pub(crate) async fn write_shell_detach_unrecorded(
3530        &mut self,
3531        turn: u32,
3532        work_id: &str,
3533        binary: &str,
3534        reason: &str,
3535    ) -> std::io::Result<()> {
3536        let event = ShellDetachUnrecordedEvent {
3537            event_type: "shell_detach_unrecorded",
3538            event_id: new_event_id(),
3539            parent_id: self.turn_parent(),
3540            session_id: self.session_id.clone(),
3541            timestamp: timestamp_ms(),
3542            turn,
3543            task_id: self.active_task_id.clone(),
3544            work_id: work_id.to_string(),
3545            binary: binary.to_string(),
3546            reason: reason.to_string(),
3547        };
3548        self.write_event(&event).await
3549    }
3550
3551    /// Records how one tool call's safety checks ended. Parented to the turn.
3552    pub(crate) async fn write_safety_check(
3553        &mut self,
3554        turn: u32,
3555        tool_call_id: &str,
3556        tool: &str,
3557        checks: &serde_json::Value,
3558        outcome: &'static str,
3559    ) -> std::io::Result<()> {
3560        let event = SafetyCheckEvent {
3561            event_type: "safety_check",
3562            event_id: new_event_id(),
3563            parent_id: self.turn_parent(),
3564            session_id: self.session_id.clone(),
3565            timestamp: timestamp_ms(),
3566            turn,
3567            task_id: self.active_task_id.clone(),
3568            tool_call_id: tool_call_id.to_string(),
3569            tool: tool.to_string(),
3570            checks: checks.clone(),
3571            outcome,
3572        };
3573        self.write_event(&event).await
3574    }
3575
3576    /// Records a task the door let in. `caller` is who the door let it in as and `caller_member`
3577    /// the roster name a formation token carried; a task with a member is recorded as the
3578    /// member's, whatever credential the token was minted for. The message text is hashed under
3579    /// `meta` and `content` and stored as a blob under `content`; the JSONL never holds it.
3580    #[allow(clippy::too_many_arguments)]
3581    pub(crate) async fn write_a2a_task_received(
3582        &mut self,
3583        task_id: &str,
3584        context_id: &str,
3585        message_id: &str,
3586        traceparent_from_caller: Option<&str>,
3587        caller: &crate::identity::TaskCaller,
3588        caller_member: Option<&str>,
3589        member_request_id: Option<&str>,
3590        message_text: &str,
3591    ) -> std::io::Result<()> {
3592        use crate::identity::TaskCaller;
3593        let (received_from, caller_credential) = match (caller_member, caller) {
3594            (Some(_), _) => (RECEIVED_FROM_MEMBER, None),
3595            (None, TaskCaller::Operator) => (RECEIVED_FROM_OPERATOR, None),
3596            (None, TaskCaller::Public) => (RECEIVED_FROM_PUBLIC, None),
3597            (None, TaskCaller::Credential(name)) => {
3598                (RECEIVED_FROM_CREDENTIAL, Some(name.to_string()))
3599            }
3600        };
3601        let message_sha256 = if self.capture.captures_hashes() {
3602            let sha = murmur_artifact::sha256_hex(message_text.as_bytes());
3603            if self.capture.captures_content() {
3604                self.blobs.put(&sha, message_text.as_bytes()).await?;
3605            }
3606            Some(sha)
3607        } else {
3608            None
3609        };
3610        let event = A2aTaskReceivedEvent {
3611            event_type: "a2a_task_received",
3612            event_id: new_event_id(),
3613            parent_id: self.session_parent(),
3614            session_id: self.session_id.clone(),
3615            timestamp: timestamp_ms(),
3616            task_id: task_id.to_string(),
3617            context_id: context_id.to_string(),
3618            message_id: message_id.to_string(),
3619            traceparent_from_caller: traceparent_from_caller.map(str::to_string),
3620            caller: received_from,
3621            caller_member: caller_member.map(str::to_string),
3622            caller_credential,
3623            member_request_id: member_request_id.map(str::to_string),
3624            message_sha256,
3625        };
3626        self.write_event(&event).await
3627    }
3628
3629    pub(crate) async fn write_a2a_send(
3630        &mut self,
3631        peer_url: &str,
3632        message_id: &str,
3633        task_id: &str,
3634        context_id: &str,
3635        traceparent: Option<&str>,
3636        trust: TrustClass,
3637    ) -> std::io::Result<()> {
3638        let event = A2aSendEvent {
3639            event_type: "a2a_send",
3640            event_id: new_event_id(),
3641            parent_id: self.session_parent(),
3642            session_id: self.session_id.clone(),
3643            timestamp: timestamp_ms(),
3644            peer_url: peer_url.to_string(),
3645            message_id: message_id.to_string(),
3646            task_id: task_id.to_string(),
3647            context_id: context_id.to_string(),
3648            traceparent: traceparent.map(str::to_string),
3649            trust: trust.as_str().to_string(),
3650        };
3651        self.write_event(&event).await
3652    }
3653
3654    /// Records a successful pull of `pulled` (`manage.pull()` or `pull-artifact`), as the store
3655    /// summarised it after the lock was written. Parented to the session node.
3656    pub(crate) async fn write_artifact_pulled(
3657        &mut self,
3658        pulled: &crate::runtime::PulledArtifact,
3659    ) -> std::io::Result<()> {
3660        let artifact = &pulled.summary;
3661        let session = match &artifact.origin {
3662            LockOrigin::Runtime { session } => Some(session.clone()),
3663            LockOrigin::Operator => None,
3664        };
3665        let event = ArtifactPulledEvent {
3666            event_type: "artifact_pulled",
3667            event_id: new_event_id(),
3668            parent_id: self.session_parent(),
3669            session_id: self.session_id.clone(),
3670            timestamp: timestamp_ms(),
3671            name: artifact.name.clone(),
3672            version: artifact.version.clone(),
3673            runtime: artifact.runtime.as_str(),
3674            origin: artifact.origin.as_str(),
3675            session,
3676            trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
3677            fetched_from: pulled.fetched_from.clone(),
3678            owner_id: pulled.owner_id,
3679        };
3680        self.write_event(&event).await
3681    }
3682
3683    /// `part_kinds` and `reference_task_ids` describe the A2A message the task arrived as, and are
3684    /// empty for any other task.
3685    #[cfg(test)]
3686    #[allow(clippy::too_many_arguments)]
3687    pub(crate) async fn write_task_start(
3688        &mut self,
3689        task_id: &str,
3690        context_id: &str,
3691        source: &str,
3692        provenance: TaskProvenance,
3693        message_parts_bytes: u64,
3694        part_kinds: &[&'static str],
3695        reference_task_ids: &[String],
3696    ) -> std::io::Result<()> {
3697        self.write_task_start_with_attachments(
3698            task_id,
3699            context_id,
3700            source,
3701            provenance,
3702            message_parts_bytes,
3703            part_kinds,
3704            reference_task_ids,
3705            Vec::new(),
3706        )
3707        .await
3708    }
3709
3710    /// The `task_start` record. `attachments` lists the images the task's message carried, and an
3711    /// empty list omits the field.
3712    #[allow(clippy::too_many_arguments)]
3713    pub(crate) async fn write_task_start_with_attachments(
3714        &mut self,
3715        task_id: &str,
3716        context_id: &str,
3717        source: &str,
3718        provenance: TaskProvenance,
3719        message_parts_bytes: u64,
3720        part_kinds: &[&'static str],
3721        reference_task_ids: &[String],
3722        attachments: Vec<AttachmentRecord>,
3723    ) -> std::io::Result<()> {
3724        // Drained under the ids in force before this task, which the lines below replace.
3725        self.write_latched().await?;
3726        // Reset per-task counters for this task
3727        self.task_turns = 0;
3728        self.task_input_tokens = 0;
3729        self.task_output_tokens = 0;
3730        self.task_tool_calls = 0;
3731        self.task_shell_calls = 0;
3732        self.task_agent_turns = 0;
3733        self.task_hook_inferences = 0;
3734        self.task_capped_turns = 0;
3735        let started = Instant::now();
3736        self.task_start_instant = Some(started);
3737        self.active_task_id = Some(task_id.to_string());
3738        if let Some(status) = &self.session_status {
3739            status.task_started(task_id, started);
3740        }
3741
3742        let event_id = new_event_id();
3743        self.task_event_id = Some(event_id.clone());
3744        self.turn_event_id = None;
3745
3746        let event = TaskStartEvent {
3747            event_type: "task_start",
3748            event_id: event_id.clone(),
3749            parent_id: self.session_parent(),
3750            session_id: self.session_id.clone(),
3751            timestamp: timestamp_ms(),
3752            task_id: task_id.to_string(),
3753            context_id: context_id.to_string(),
3754            source: source.to_string(),
3755            origin: provenance.origin().as_str().to_string(),
3756            trust: provenance.trust().as_str().to_string(),
3757            // One rule for every call site, so the recorded lane cannot disagree with the lane
3758            // `LaneQueue` filed the task under — both read the same provenance.
3759            lane: TaskLane::for_origin(provenance.origin())
3760                .as_str()
3761                .to_string(),
3762            message_parts_bytes,
3763            part_kinds: part_kinds.to_vec(),
3764            reference_task_ids: reference_task_ids.to_vec(),
3765            attachments,
3766        };
3767        self.write_event(&event).await
3768    }
3769
3770    pub(crate) async fn write_task_end(
3771        &mut self,
3772        task_id: &str,
3773        exit_status: &str,
3774        reopen_count: u32,
3775        running_commands: Vec<String>,
3776        keep_working: Option<KeepWorkingSummary>,
3777        done_check: Option<DoneCheckSummary>,
3778    ) -> std::io::Result<()> {
3779        // Drained inside the task, before the lines below clear its ids.
3780        self.write_latched().await?;
3781        let duration_ms = self
3782            .task_start_instant
3783            .take()
3784            .map(|t| t.elapsed().as_millis().try_into().unwrap_or(u64::MAX))
3785            .unwrap_or(0);
3786
3787        let event = TaskEndEvent {
3788            event_type: "task_end",
3789            event_id: new_event_id(),
3790            parent_id: self.task_parent(),
3791            session_id: self.session_id.clone(),
3792            timestamp: timestamp_ms(),
3793            task_id: task_id.to_string(),
3794            exit_status: exit_status.to_string(),
3795            duration_ms,
3796            turns: self.task_turns,
3797            input_tokens: self.task_input_tokens,
3798            output_tokens: self.task_output_tokens,
3799            tool_calls: self.task_tool_calls,
3800            shell_calls: self.task_shell_calls,
3801            reopen_count,
3802            capped_turns: self.task_capped_turns,
3803            running_commands,
3804            keep_working,
3805            done_check,
3806        };
3807        self.active_task_id = None;
3808        self.task_event_id = None;
3809        self.turn_event_id = None;
3810        self.task_agent_turns = 0;
3811        if let Some(status) = &self.session_status {
3812            status.task_ended();
3813        }
3814        self.write_event(&event).await
3815    }
3816
3817    /// Record that a person stopped this task, at the point the loop stopped.
3818    ///
3819    /// Leaves `active_task_id` and the task's parent id alone: for a running task the terminal
3820    /// `task_end` still follows and closes the frame. A task cancelled while queued was never
3821    /// started, so this record hangs off the session.
3822    pub(crate) async fn write_task_canceled(
3823        &mut self,
3824        task_id: &str,
3825        turn: Option<u32>,
3826        phase: &str,
3827        detached_work_ids: Vec<String>,
3828        delegation_ids: Vec<String>,
3829    ) -> std::io::Result<()> {
3830        let event = TaskCanceledEvent {
3831            event_type: "task_canceled",
3832            event_id: new_event_id(),
3833            parent_id: self.task_parent(),
3834            session_id: self.session_id.clone(),
3835            timestamp: timestamp_ms(),
3836            task_id: task_id.to_string(),
3837            turn,
3838            phase: phase.to_string(),
3839            detached_work_ids,
3840            delegation_ids,
3841        };
3842        self.write_event(&event).await
3843    }
3844
3845    /// Record that a queued task was refused when the session stopped taking work, with `cause`
3846    /// from the `TASK_REJECTED_*` vocabulary. Written whatever `trace.capture` is.
3847    ///
3848    /// Leaves `active_task_id` alone: the refused task never started, so there is no task frame
3849    /// to open or close, and with no task active the record hangs off the session.
3850    pub(crate) async fn write_task_rejected(
3851        &mut self,
3852        task_id: &str,
3853        context_id: &str,
3854        source: &str,
3855        cause: &str,
3856        reason: &str,
3857    ) -> std::io::Result<()> {
3858        let event = TaskRejectedEvent {
3859            event_type: "task_rejected",
3860            event_id: new_event_id(),
3861            parent_id: self.task_parent(),
3862            session_id: self.session_id.clone(),
3863            timestamp: timestamp_ms(),
3864            task_id: task_id.to_string(),
3865            context_id: context_id.to_string(),
3866            source: source.to_string(),
3867            cause: cause.to_string(),
3868            reason: reason.to_string(),
3869        };
3870        self.write_event(&event).await
3871    }
3872
3873    /// Record that the task attempt in progress failed, with `cause` from the `TASK_FAILED_*`
3874    /// vocabulary. Written whatever `trace.capture` is.
3875    ///
3876    /// Leaves the task frame open, as [`Self::write_task_canceled`] does: the `task_end` that
3877    /// follows closes it. The reason, capped, is kept for [`Self::last_task_failure`].
3878    pub(crate) async fn write_task_failed(
3879        &mut self,
3880        turn: Option<u32>,
3881        cause: &str,
3882        reason: &str,
3883    ) -> std::io::Result<()> {
3884        let reason = crate::agent::truncate_on_char_boundary(reason, MAX_TASK_FAILURE_REASON_BYTES)
3885            .to_string();
3886        let event = TaskFailedEvent {
3887            event_type: "task_failed",
3888            event_id: new_event_id(),
3889            parent_id: self.task_parent(),
3890            session_id: self.session_id.clone(),
3891            timestamp: timestamp_ms(),
3892            task_id: self.active_task_id.clone(),
3893            turn,
3894            cause: cause.to_string(),
3895            reason: reason.clone(),
3896        };
3897        self.task_failures_written += 1;
3898        self.last_task_failure = Some(reason);
3899        self.write_event(&event).await
3900    }
3901
3902    /// Record one refusal the workdir helper latched.
3903    pub(crate) async fn write_workdir_path_refused(
3904        &mut self,
3905        refusal: &crate::workdir_fs::WorkdirRefusal,
3906    ) -> std::io::Result<()> {
3907        let event = WorkdirPathRefusedEvent {
3908            event_type: "workdir_path_refused",
3909            event_id: new_event_id(),
3910            parent_id: self.task_parent(),
3911            session_id: self.session_id.clone(),
3912            timestamp: timestamp_ms(),
3913            task_id: self.active_task_id.clone(),
3914            path: &refusal.path,
3915            operation: refusal.operation,
3916            refused_at: &refusal.refused_at,
3917            reason: refusal.reason,
3918        };
3919        self.write_event(&event).await
3920    }
3921
3922    /// The `reason` of the last `task_failed` line this writer wrote, or `None` before the first.
3923    pub(crate) fn last_task_failure(&self) -> Option<&str> {
3924        self.last_task_failure.as_deref()
3925    }
3926
3927    /// How many `task_failed` lines this writer has written. Compared across one run to tell
3928    /// whether that run wrote its own.
3929    pub(crate) fn task_failures_written(&self) -> u64 {
3930        self.task_failures_written
3931    }
3932
3933    /// Record that an `on-task-end` hook reopened the task. Written by the runtime's
3934    /// per-task reopen loop between two agent-loop attempts, once per reopen, before
3935    /// the terminal `task_end` record. `reopen_number` is 1-based.
3936    pub(crate) async fn write_task_reopened(
3937        &mut self,
3938        task_id: &str,
3939        hook_name: &str,
3940        reason: &str,
3941        reopen_number: u32,
3942        attempt_context: &'static str,
3943        turns_remaining: Option<u32>,
3944    ) -> std::io::Result<()> {
3945        let event = TaskReopenedEvent {
3946            event_type: "task_reopened",
3947            event_id: new_event_id(),
3948            parent_id: self.task_parent(),
3949            session_id: self.session_id.clone(),
3950            timestamp: timestamp_ms(),
3951            task_id: task_id.to_string(),
3952            hook_name: hook_name.to_string(),
3953            reason: reason.to_string(),
3954            reopen_number,
3955            attempt_context,
3956            turns_remaining,
3957        };
3958        self.write_event(&event).await
3959    }
3960
3961    /// Record that the task continues with one round of handed-off work's outcomes. Written by
3962    /// the runtime's per-task loop once per continuation, before the continued attempt's first
3963    /// `inference`. `continuation_number` is 1-based.
3964    pub(crate) async fn write_task_continued(
3965        &mut self,
3966        task_id: &str,
3967        continuation_number: u32,
3968        member_calls: &[String],
3969        delegations: &[String],
3970        waited_ms: u64,
3971        turns_remaining: Option<u32>,
3972    ) -> std::io::Result<()> {
3973        let event = TaskContinuedEvent {
3974            event_type: "task_continued",
3975            event_id: new_event_id(),
3976            parent_id: self.task_parent(),
3977            session_id: self.session_id.clone(),
3978            timestamp: timestamp_ms(),
3979            task_id: task_id.to_string(),
3980            continuation_number,
3981            member_calls,
3982            delegations,
3983            waited_ms,
3984            turns_remaining,
3985        };
3986        self.write_event(&event).await
3987    }
3988
3989    /// Record that `lifecycle.keep_working` sent its message as the task's `number`th continuation
3990    /// (1-based), before the continued attempt's first `inference`.
3991    pub(crate) async fn write_keep_working(
3992        &mut self,
3993        task_id: &str,
3994        number: u32,
3995        limit: u32,
3996        turns_remaining: Option<u32>,
3997        message_bytes: u64,
3998    ) -> std::io::Result<()> {
3999        let event = KeepWorkingEvent {
4000            event_type: "keep_working",
4001            event_id: new_event_id(),
4002            parent_id: self.task_parent(),
4003            session_id: self.session_id.clone(),
4004            timestamp: timestamp_ms(),
4005            task_id: task_id.to_string(),
4006            number,
4007            limit,
4008            turns_remaining,
4009            message_bytes,
4010        };
4011        if let Some(status) = &self.session_status {
4012            status.keep_working_sent(number);
4013        }
4014        self.write_event(&event).await
4015    }
4016
4017    /// Record one run of `lifecycle.done_check`'s command. A run that reopened the task feeds the
4018    /// session status the task's reopens so far, `reopens`.
4019    pub(crate) async fn write_done_check(
4020        &mut self,
4021        run: &DoneCheckRun<'_>,
4022        reopens: u32,
4023    ) -> std::io::Result<()> {
4024        let event = DoneCheckEvent {
4025            event_type: "done_check",
4026            event_id: new_event_id(),
4027            parent_id: self.task_parent(),
4028            session_id: self.session_id.clone(),
4029            timestamp: timestamp_ms(),
4030            run,
4031        };
4032        if run.reopened {
4033            if let Some(status) = &self.session_status {
4034                status.done_check_reopened(reopens);
4035            }
4036        }
4037        self.write_event(&event).await
4038    }
4039
4040    /// Cumulative turns consumed by the active task so far, across however many
4041    /// agent-loop attempts have run since the last [`Self::write_task_start`]. Read by
4042    /// the reopen loop to compute the remaining `max_turns` budget for the next attempt
4043    /// so reopening never grants turns past the capsule's ceiling.
4044    ///
4045    /// A hook's counted `run-inference` call is one of them. `inference.max_turns` is the cap on
4046    /// inference calls per task, and `task_end.turns` and `session_end.total_turns` count the
4047    /// same calls; a hook that calls `run-inference` every turn is per-task spend that the cap
4048    /// exists to bound, and the spend ceiling is per capsule, not per task.
4049    pub(crate) fn task_turns(&self) -> u32 {
4050        self.task_turns
4051    }
4052
4053    /// How many of [`Self::task_turns`] were hook `run-inference` calls.
4054    pub(crate) fn task_hook_inferences(&self) -> u32 {
4055        self.task_hook_inferences
4056    }
4057
4058    /// The number the active task's next agent-loop turn takes: how many agent-loop `inference`
4059    /// lines it has written since [`Self::write_task_start`], across every attempt. 0 outside a
4060    /// task frame. Not the `max_turns` budget, which is [`Self::task_turns`].
4061    pub(crate) fn task_agent_turns(&self) -> u32 {
4062        self.task_agent_turns
4063    }
4064
4065    pub(crate) async fn write_compaction(
4066        &mut self,
4067        turn: u32,
4068        tokens_before: u64,
4069        tokens_before_source: OccupancyBasis,
4070        tokens_after: u64,
4071    ) -> std::io::Result<()> {
4072        let event = CompactionEvent {
4073            event_type: "compaction",
4074            event_id: new_event_id(),
4075            parent_id: self.turn_parent(),
4076            session_id: self.session_id.clone(),
4077            timestamp: timestamp_ms(),
4078            turn,
4079            task_id: self.active_task_id.clone(),
4080            tokens_before,
4081            tokens_before_source,
4082            tokens_after,
4083        };
4084        self.write_event(&event).await
4085    }
4086
4087    /// Record that compaction was attempted and declined, leaving the context over budget.
4088    /// `tokens` is the occupancy at the moment of the decline and `tokens_source` the kind of
4089    /// number it is; `reason` is
4090    /// [`COMPACTION_DECLINED_NO_HOOK_REPLACEMENT`] or
4091    /// [`COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL`].
4092    pub(crate) async fn write_compaction_declined(
4093        &mut self,
4094        turn: u32,
4095        tokens: u64,
4096        tokens_source: OccupancyBasis,
4097        reason: &str,
4098    ) -> std::io::Result<()> {
4099        let event = CompactionDeclinedEvent {
4100            event_type: "compaction_declined",
4101            event_id: new_event_id(),
4102            parent_id: self.turn_parent(),
4103            session_id: self.session_id.clone(),
4104            timestamp: timestamp_ms(),
4105            turn,
4106            task_id: self.active_task_id.clone(),
4107            tokens,
4108            tokens_source,
4109            reason: reason.to_string(),
4110        };
4111        self.write_event(&event).await
4112    }
4113
4114    /// Record what the runtime did with an `on-task-start` hook's `seed-context`.
4115    ///
4116    /// Written for every outcome, committed or not: `tokens` is what reached the context
4117    /// (`0` on a rejection), `proposed_tokens` is what the hook returned, and
4118    /// `budget_tokens` is the ceiling it was measured against. `reason` is `Some` only for
4119    /// [`SEED_OUTCOME_REJECTED`]. Task-scoped, like `task_start`/`task_end`.
4120    #[allow(clippy::too_many_arguments)]
4121    pub(crate) async fn write_context_seed(
4122        &mut self,
4123        hook_name: &str,
4124        tokens: u64,
4125        proposed_tokens: u64,
4126        budget_tokens: u64,
4127        outcome: &str,
4128        reason: Option<&str>,
4129        message_ids: Vec<String>,
4130    ) -> std::io::Result<()> {
4131        let event = ContextSeedEvent {
4132            event_type: "context_seed",
4133            event_id: new_event_id(),
4134            parent_id: self.task_parent(),
4135            session_id: self.session_id.clone(),
4136            timestamp: timestamp_ms(),
4137            task_id: self.active_task_id.clone(),
4138            hook_name: hook_name.to_string(),
4139            tokens,
4140            proposed_tokens,
4141            budget_tokens,
4142            outcome: outcome.to_string(),
4143            reason: reason.map(str::to_string),
4144            message_ids,
4145        };
4146        self.write_event(&event).await
4147    }
4148
4149    /// Record one store's deletion, naming the reason and everything that went.
4150    ///
4151    /// Session-scoped: `parent_id` is the session node, so the deletion hangs off the launch that
4152    /// performed it rather than off whatever task happened to be open. `messages_dropped` is
4153    /// `Some` only for [`RETENTION_REASON_MAX_MESSAGES`].
4154    pub(crate) async fn write_retention(
4155        &mut self,
4156        store: &str,
4157        reason: &str,
4158        targets: Vec<String>,
4159        messages_dropped: Option<u64>,
4160    ) -> std::io::Result<()> {
4161        let event = RetentionEvent {
4162            event_type: "retention",
4163            event_id: new_event_id(),
4164            parent_id: self.session_parent(),
4165            session_id: self.session_id.clone(),
4166            timestamp: timestamp_ms(),
4167            store: store.to_string(),
4168            reason: reason.to_string(),
4169            removed: u32::try_from(targets.len()).unwrap_or(u32::MAX),
4170            targets,
4171            messages_dropped,
4172        };
4173        self.write_event(&event).await
4174    }
4175
4176    /// Record that a policy hook refused a call before it ran. The only account of a call the
4177    /// model asked for and never got: nothing ran, so no `tool_call` or `shell` record
4178    /// accompanies it.
4179    pub(crate) async fn write_call_denied(
4180        &mut self,
4181        turn: u32,
4182        event: &str,
4183        hook_name: &str,
4184        target: &str,
4185        reason: &str,
4186    ) -> std::io::Result<()> {
4187        let event = CallDeniedEvent {
4188            event_type: "call_denied",
4189            event_id: new_event_id(),
4190            parent_id: self.turn_parent(),
4191            session_id: self.session_id.clone(),
4192            timestamp: timestamp_ms(),
4193            turn,
4194            event: event.to_string(),
4195            hook_name: hook_name.to_string(),
4196            target: target.to_string(),
4197            reason: reason.to_string(),
4198        };
4199        self.write_event(&event).await
4200    }
4201
4202    /// Record that a call was refused because its input lacked a field its tool's `input_schema`
4203    /// requires. A `tool_call` record with `status: "error"` follows it on both transports, so
4204    /// unlike [`Self::write_call_denied`] this is not the only line the call leaves. An empty
4205    /// `tool_call_id` records as `null`, as in [`Self::write_tool_call`].
4206    pub(crate) async fn write_tool_input_refused(
4207        &mut self,
4208        turn: u32,
4209        tool_name: &str,
4210        tool_call_id: Option<&str>,
4211        missing: &[String],
4212        reason: &str,
4213    ) -> std::io::Result<()> {
4214        let event = ToolInputRefusedEvent {
4215            event_type: "tool_input_refused",
4216            event_id: new_event_id(),
4217            parent_id: self.turn_parent(),
4218            session_id: self.session_id.clone(),
4219            timestamp: timestamp_ms(),
4220            turn,
4221            tool_name: tool_name.to_string(),
4222            tool_call_id: tool_call_id.filter(|id| !id.is_empty()).map(str::to_string),
4223            missing: missing.to_vec(),
4224            reason: reason.to_string(),
4225        };
4226        self.write_event(&event).await
4227    }
4228
4229    /// Record that a driver call failed transiently and is about to be made again after
4230    /// `retry.delay_ms`. `retry.origin` is the `hook:<name>` of a hook's `run-inference` call,
4231    /// `None` for an agent-loop turn. The error text is capped at
4232    /// [`MAX_TASK_FAILURE_REASON_BYTES`].
4233    pub(crate) async fn write_inference_retry(
4234        &mut self,
4235        turn: u32,
4236        retry: &InferenceRetryRecord,
4237    ) -> std::io::Result<()> {
4238        let event = InferenceRetryEvent {
4239            event_type: "inference_retry",
4240            event_id: new_event_id(),
4241            parent_id: self.turn_parent(),
4242            session_id: self.session_id.clone(),
4243            timestamp: timestamp_ms(),
4244            turn,
4245            task_id: self.active_task_id.clone(),
4246            attempt: retry.attempt,
4247            max_retries: retry.max_retries,
4248            reason: retry.reason.wire_name(),
4249            provider_status: retry.provider_status,
4250            error: crate::agent::truncate_on_char_boundary(
4251                &retry.error,
4252                MAX_TASK_FAILURE_REASON_BYTES,
4253            ),
4254            delay_ms: retry.delay_ms,
4255            retry_after_ms: retry.retry_after_ms,
4256            charged_tokens: retry.charged_tokens,
4257            origin: retry.origin.as_deref(),
4258        };
4259        self.write_event(&event).await
4260    }
4261
4262    /// Record that a spend ceiling refused a driver call before it was sent. `origin` is the
4263    /// `hook:<name>` of a hook's `run-inference` call, `None` for an agent-loop turn.
4264    pub(crate) async fn write_spend_ceiling_reached(
4265        &mut self,
4266        turn: u32,
4267        refusal: &crate::spend::SpendRefusal,
4268        origin: Option<&str>,
4269    ) -> std::io::Result<()> {
4270        let event = SpendCeilingReachedEvent {
4271            event_type: "spend_ceiling_reached",
4272            event_id: new_event_id(),
4273            parent_id: self.turn_parent(),
4274            session_id: self.session_id.clone(),
4275            timestamp: timestamp_ms(),
4276            turn,
4277            task_id: self.active_task_id.clone(),
4278            refusal,
4279            origin,
4280        };
4281        self.write_event(&event).await
4282    }
4283
4284    /// Record that the manifest's `capabilities.filesystem.read_only` refused a call before it
4285    /// ran. Like [`Self::write_call_denied`] it is the only account of a call the model asked for
4286    /// and never got — nothing ran, so no `tool_call` or `shell` record accompanies it.
4287    #[allow(clippy::too_many_arguments)]
4288    pub(crate) async fn write_protected_path_denied(
4289        &mut self,
4290        turn: u32,
4291        call: &str,
4292        target: &str,
4293        path: &str,
4294        rule: &str,
4295        signal: &str,
4296        reason: &str,
4297    ) -> std::io::Result<()> {
4298        let event = ProtectedPathDeniedEvent {
4299            event_type: "protected_path_denied",
4300            event_id: new_event_id(),
4301            parent_id: self.turn_parent(),
4302            session_id: self.session_id.clone(),
4303            timestamp: timestamp_ms(),
4304            turn,
4305            call: call.to_string(),
4306            target: target.to_string(),
4307            path: path.to_string(),
4308            rule: rule.to_string(),
4309            signal: signal.to_string(),
4310            reason: reason.to_string(),
4311        };
4312        self.write_event(&event).await
4313    }
4314
4315    /// Record that a hook returned a `hook-output` arm the lifecycle event does not
4316    /// honor. Written by the agent loop from a buffered [`crate::hooks::DispatchFault`]
4317    /// drained just before `session_end`. Non-fatal: the session already continued as
4318    /// if the hook had returned `none`; this only makes the discard visible to
4319    /// `mur trace show` and anything reading the session trace.
4320    pub(crate) async fn write_hook_dispatch_error(
4321        &mut self,
4322        hook_name: &str,
4323        event: &str,
4324        arm: &str,
4325    ) -> std::io::Result<()> {
4326        let event = HookDispatchErrorEvent {
4327            event_type: "hook_dispatch_error",
4328            event_id: new_event_id(),
4329            parent_id: self.session_parent(),
4330            session_id: self.session_id.clone(),
4331            timestamp: timestamp_ms(),
4332            hook_name: hook_name.to_string(),
4333            event: event.to_string(),
4334            arm: arm.to_string(),
4335        };
4336        self.write_event(&event).await
4337    }
4338
4339    // ── The harness a `transport: process` session drives ────────────────────────────────
4340    //
4341    // Eight events account for one harness run: what was spawned, what it said about itself,
4342    // what it warned or noted, how it failed, how it was stopped, and how it ended. None of them
4343    // ever carries an argument value, an environment value or a file's contents — the bridge's
4344    // bearer token reaches the harness through exactly those three, and `trace.jsonl` is durable.
4345
4346    /// Record the harness about to be spawned: written after the driver planned the run and
4347    /// before the process exists, so a spawn that goes wrong still has its plan on record.
4348    pub(crate) async fn write_harness_start(&mut self, start: HarnessStart) -> std::io::Result<()> {
4349        if self.capture.captures_content() {
4350            self.blobs
4351                .put(&start.system_prompt_sha256, start.system_prompt.as_bytes())
4352                .await?;
4353        }
4354        let event = HarnessStartEvent {
4355            event_type: "harness_start",
4356            event_id: new_event_id(),
4357            parent_id: self.task_parent(),
4358            session_id: self.session_id.clone(),
4359            timestamp: timestamp_ms(),
4360            task_id: self.active_task_id.clone(),
4361            start,
4362        };
4363        self.write_event(&event).await
4364    }
4365
4366    /// Record a warning the run raised, beside the line it printed to stderr.
4367    pub(crate) async fn write_harness_warning(
4368        &mut self,
4369        code: &str,
4370        message: &str,
4371    ) -> std::io::Result<()> {
4372        let event = HarnessWarningEvent {
4373            event_type: "harness_warning",
4374            event_id: new_event_id(),
4375            parent_id: self.task_parent(),
4376            session_id: self.session_id.clone(),
4377            timestamp: timestamp_ms(),
4378            task_id: self.active_task_id.clone(),
4379            code: code.to_string(),
4380            message: message.to_string(),
4381        };
4382        self.write_event(&event).await
4383    }
4384
4385    /// Record the session the harness reports it opened, and how it is billing for it.
4386    pub(crate) async fn write_harness_session(
4387        &mut self,
4388        harness_session_id: &str,
4389        auth: &str,
4390        model: Option<&str>,
4391    ) -> std::io::Result<()> {
4392        let event = HarnessSessionEvent {
4393            event_type: "harness_session",
4394            event_id: new_event_id(),
4395            parent_id: self.task_parent(),
4396            session_id: self.session_id.clone(),
4397            timestamp: timestamp_ms(),
4398            task_id: self.active_task_id.clone(),
4399            harness_session_id: harness_session_id.to_string(),
4400            auth: auth.to_string(),
4401            model: model.map(str::to_string),
4402        };
4403        self.write_event(&event).await
4404    }
4405
4406    /// Record the harness session a person asked this capsule to forget, and who asked.
4407    pub(crate) async fn write_harness_session_forgotten(
4408        &mut self,
4409        context_id: &str,
4410        harness_session_id: &str,
4411        requested_by: &str,
4412    ) -> std::io::Result<()> {
4413        let event = HarnessSessionForgottenEvent {
4414            event_type: "harness_session_forgotten",
4415            event_id: new_event_id(),
4416            parent_id: self.task_parent(),
4417            session_id: self.session_id.clone(),
4418            timestamp: timestamp_ms(),
4419            task_id: self.active_task_id.clone(),
4420            context_id: context_id.to_string(),
4421            harness_session_id: harness_session_id.to_string(),
4422            requested_by: requested_by.to_string(),
4423        };
4424        self.write_event(&event).await
4425    }
4426
4427    /// Record that the harness is retrying a request to its own provider.
4428    pub(crate) async fn write_harness_retry(
4429        &mut self,
4430        attempt: u32,
4431        reason: &str,
4432    ) -> std::io::Result<()> {
4433        let event = HarnessRetryEvent {
4434            event_type: "harness_retry",
4435            event_id: new_event_id(),
4436            parent_id: self.task_parent(),
4437            session_id: self.session_id.clone(),
4438            timestamp: timestamp_ms(),
4439            task_id: self.active_task_id.clone(),
4440            attempt,
4441            reason: reason.to_string(),
4442        };
4443        self.write_event(&event).await
4444    }
4445
4446    /// Record a diagnostic the driver produced for the trace alone — a line it could not read,
4447    /// or a result the runtime could not pair with a call.
4448    pub(crate) async fn write_harness_note(&mut self, text: &str) -> std::io::Result<()> {
4449        let event = HarnessTextEvent {
4450            event_type: "harness_note",
4451            event_id: new_event_id(),
4452            parent_id: self.task_parent(),
4453            session_id: self.session_id.clone(),
4454            timestamp: timestamp_ms(),
4455            task_id: self.active_task_id.clone(),
4456            text: text.to_string(),
4457        };
4458        self.write_event(&event).await
4459    }
4460
4461    /// Record one report of the subscription windows the harness says are used, already
4462    /// validated, and hand it to the session status, which keeps the latest window per name.
4463    pub(crate) async fn write_harness_quota(
4464        &mut self,
4465        windows: &[crate::process_driver::QuotaWindow],
4466    ) -> std::io::Result<()> {
4467        if let Some(status) = &self.session_status {
4468            status.record_quota(windows);
4469        }
4470        let event = HarnessQuotaEvent {
4471            event_type: "harness_quota",
4472            event_id: new_event_id(),
4473            parent_id: self.task_parent(),
4474            session_id: self.session_id.clone(),
4475            timestamp: timestamp_ms(),
4476            task_id: self.active_task_id.clone(),
4477            windows: windows
4478                .iter()
4479                .map(|window| HarnessQuotaWindow {
4480                    name: &window.name,
4481                    used: window.used,
4482                    resets_at_ms: window.resets_at,
4483                    window_minutes: window.window_minutes,
4484                    state: window
4485                        .state
4486                        .map(crate::process_driver::QuotaState::wire_name),
4487                })
4488                .collect(),
4489        };
4490        self.write_event(&event).await
4491    }
4492
4493    /// Record the harness's prediction of what the person will ask next.
4494    pub(crate) async fn write_harness_suggestion(&mut self, text: &str) -> std::io::Result<()> {
4495        let event = HarnessTextEvent {
4496            event_type: "harness_suggestion",
4497            event_id: new_event_id(),
4498            parent_id: self.task_parent(),
4499            session_id: self.session_id.clone(),
4500            timestamp: timestamp_ms(),
4501            task_id: self.active_task_id.clone(),
4502            text: text.to_string(),
4503        };
4504        self.write_event(&event).await
4505    }
4506
4507    /// Record a failed turn. `source` is `"harness"` when the harness reported the failure and
4508    /// `"runtime"` when this runtime ended the turn itself.
4509    pub(crate) async fn write_harness_failed(
4510        &mut self,
4511        kind: &str,
4512        message: &str,
4513        source: &str,
4514    ) -> std::io::Result<()> {
4515        let event = HarnessFailedEvent {
4516            event_type: "harness_failed",
4517            event_id: new_event_id(),
4518            parent_id: self.task_parent(),
4519            session_id: self.session_id.clone(),
4520            timestamp: timestamp_ms(),
4521            task_id: self.active_task_id.clone(),
4522            kind: kind.to_string(),
4523            message: message.to_string(),
4524            source: source.to_string(),
4525        };
4526        self.write_event(&event).await
4527    }
4528
4529    /// Record the interrupt a cancelled task sent its harness: the method the driver declared,
4530    /// whether it went out, and how long the harness has before it is killed.
4531    pub(crate) async fn write_harness_interrupt(
4532        &mut self,
4533        method: &str,
4534        delivered: bool,
4535        grace_ms: u64,
4536    ) -> std::io::Result<()> {
4537        let event = HarnessInterruptEvent {
4538            event_type: "harness_interrupt",
4539            event_id: new_event_id(),
4540            parent_id: self.task_parent(),
4541            session_id: self.session_id.clone(),
4542            timestamp: timestamp_ms(),
4543            task_id: self.active_task_id.clone(),
4544            method: method.to_string(),
4545            delivered,
4546            grace_ms,
4547        };
4548        self.write_event(&event).await
4549    }
4550
4551    /// Record how the harness ended: once per spawned process, whatever ended it.
4552    pub(crate) async fn write_harness_exit(&mut self, exit: HarnessExit) -> std::io::Result<()> {
4553        let event = HarnessExitEvent {
4554            event_type: "harness_exit",
4555            event_id: new_event_id(),
4556            parent_id: self.task_parent(),
4557            session_id: self.session_id.clone(),
4558            timestamp: timestamp_ms(),
4559            task_id: self.active_task_id.clone(),
4560            exit,
4561        };
4562        self.write_event(&event).await
4563    }
4564
4565    pub(crate) async fn write_session_end(&mut self, exit_status: &str) -> std::io::Result<()> {
4566        let duration_ms = self
4567            .session_start_time
4568            .elapsed()
4569            .as_millis()
4570            .try_into()
4571            .unwrap_or(u64::MAX);
4572        let event = SessionEndEvent {
4573            event_type: "session_end",
4574            event_id: new_event_id(),
4575            parent_id: self.session_parent(),
4576            session_id: self.session_id.clone(),
4577            timestamp: timestamp_ms(),
4578            total_turns: self.total_turns,
4579            total_input_tokens: self.total_input_tokens,
4580            total_output_tokens: self.total_output_tokens,
4581            total_tool_calls: self.total_tool_calls,
4582            total_shell_calls: self.total_shell_calls,
4583            duration_ms,
4584            exit_status: exit_status.to_string(),
4585        };
4586        self.write_event(&event).await?;
4587        self.session_ended = true;
4588        self.sealed_head =
4589            crate::record_seal::seal_session_end(&self.records_root, &self.session_id);
4590        Ok(())
4591    }
4592
4593    /// What the seal written after `session_end` covered: the `session_end` line's hash and
4594    /// number, and the key that signed it. `None` before `session_end` or when the trace could
4595    /// not be read back.
4596    pub(crate) fn sealed_head(&self) -> Option<&crate::record_seal::SealedHead> {
4597        self.sealed_head.as_ref()
4598    }
4599
4600    /// The chain's head as the file holds it now, unsealed: what a task's OTel session span
4601    /// carries when the next task begins.
4602    pub(crate) fn current_head(&self) -> Option<crate::record_seal::SealedHead> {
4603        let head = self.chain.head().ok()?;
4604        Some(crate::record_seal::SealedHead {
4605            head: head.head,
4606            lines: head.lines,
4607            key_id: None,
4608        })
4609    }
4610
4611    /// The records directory this trace is written in.
4612    pub(crate) fn records_root(&self) -> &WorkdirRoot {
4613        &self.records_root
4614    }
4615
4616    /// Writes session_end with the accumulated totals if session_start was written but
4617    /// session_end was not (error exit paths that bypass the normal Ok() return sites).
4618    pub(crate) async fn write_session_end_if_not_ended(
4619        &mut self,
4620        exit_status: &str,
4621    ) -> std::io::Result<()> {
4622        if self.session_started && !self.session_ended {
4623            self.write_session_end(exit_status).await?;
4624        }
4625        Ok(())
4626    }
4627
4628    /// Every line is written whole as it is recorded; nothing is buffered.
4629    pub(crate) async fn flush(&mut self) -> std::io::Result<()> {
4630        Ok(())
4631    }
4632
4633    /// Write `event` as one line, after a `call_deadline` line for each firing the session's
4634    /// latch holds and a `member_request` line for each request its other latch holds. Draining
4635    /// here, ahead of every line, is what puts a cut call's `call_deadline` before the record of
4636    /// that call and every one of both before `session_end`.
4637    async fn write_event(&mut self, event: &impl Serialize) -> std::io::Result<()> {
4638        self.write_latched().await?;
4639        self.write_line(event).await
4640    }
4641
4642    /// Drains both of the session's latches: [`Self::write_call_deadlines`], then
4643    /// [`Self::write_member_requests`].
4644    async fn write_latched(&mut self) -> std::io::Result<()> {
4645        self.write_call_deadlines().await?;
4646        self.write_member_requests().await
4647    }
4648
4649    /// One `member_request` line per request [`Self::member_requests`] holds, parented to the
4650    /// session node. Nothing before `session_start`. Each line carries the task its own record
4651    /// names, never the writer's active task.
4652    async fn write_member_requests(&mut self) -> std::io::Result<()> {
4653        let Some(requests) = self
4654            .member_requests
4655            .as_ref()
4656            .filter(|_| self.session_started)
4657        else {
4658            return Ok(());
4659        };
4660        for request in requests.take() {
4661            let event = MemberRequestEvent {
4662                event_type: "member_request",
4663                event_id: new_event_id(),
4664                parent_id: self.session_parent(),
4665                session_id: self.session_id.clone(),
4666                timestamp: timestamp_ms(),
4667                reason: request.outcome.reason(),
4668                outcome: request.outcome.wire_name(),
4669                call_id: request.call_id,
4670                task_id: request.task_id,
4671                member: request.member,
4672                component: request.component,
4673                role: request.role.wire_name(),
4674                via: request.via.wire_name(),
4675                http_status: request.http_status,
4676                member_task_id: request.member_task_id,
4677                duration_ms: request.duration_ms,
4678            };
4679            self.write_line(&event).await?;
4680        }
4681        Ok(())
4682    }
4683
4684    /// One `call_deadline` line per firing [`Self::call_deadlines`] holds, parented to the turn
4685    /// as `inference_retry` is. Nothing before `session_start`, which every line hangs off.
4686    async fn write_call_deadlines(&mut self) -> std::io::Result<()> {
4687        let Some(deadlines) = self
4688            .call_deadlines
4689            .as_ref()
4690            .filter(|_| self.session_started)
4691        else {
4692            return Ok(());
4693        };
4694        for cut in deadlines.take() {
4695            let event = CallDeadlineEvent {
4696                event_type: "call_deadline",
4697                event_id: new_event_id(),
4698                parent_id: self.turn_parent(),
4699                session_id: self.session_id.clone(),
4700                timestamp: timestamp_ms(),
4701                task_id: self.active_task_id.clone(),
4702                component: cut.component,
4703                role: cut.role.wire_name(),
4704                call: cut.call,
4705                deadline_seconds: cut.deadline_seconds,
4706                elapsed_ms: cut.elapsed_ms,
4707            };
4708            self.write_line(&event).await?;
4709        }
4710        Ok(())
4711    }
4712
4713    /// Writes one event as one line through the file's [`crate::record_seal::InlineChain`],
4714    /// which every other appender of this `trace.jsonl` shares: each line ends with the hash of
4715    /// the line before it, and lands with one `write_all`.
4716    async fn write_line(&mut self, event: &impl Serialize) -> std::io::Result<()> {
4717        self.chain.append_event(event)
4718    }
4719}
4720
4721/// One element of `session_start.gateways`: an artifact's credential gateway. Never the key.
4722#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4723pub(crate) struct SessionGateway {
4724    /// The artifact whose entry declares the gateway.
4725    pub(crate) artifact: String,
4726    /// The upstream's host, with its port when `gateway.endpoint` wrote one.
4727    pub(crate) host: String,
4728    /// `"config"`, `"environment"`, `"manifest"`, or `"none"` when the entry names no key.
4729    pub(crate) credential_source: &'static str,
4730    /// Whether the gateway is admitted against the spend meter: true only for the configured
4731    /// `transport: http` driver's.
4732    pub(crate) metered: bool,
4733}
4734
4735/// One element of `session_start.runtime_artifacts`: a staged artifact whose `murmur.lock` pin a
4736/// running capsule fetched.
4737#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4738pub(crate) struct RuntimeArtifact {
4739    name: String,
4740    version: String,
4741    /// Always `"runtime"`: only runtime-origin entries are listed.
4742    origin: &'static str,
4743    /// The session the lock entry names as the puller.
4744    session: String,
4745    /// Always `"untrusted"`, derived from `origin`.
4746    trust: &'static str,
4747}
4748
4749/// One element of `session_start.host_facts`.
4750#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4751pub(crate) struct HostFactsRecord {
4752    /// The binary, canonical.
4753    binary: String,
4754    /// `"kept"` or `"derived"`.
4755    source: &'static str,
4756    /// Why the facts were derived, as [`crate::host_facts::HostFactsReason::wire_name`] spells
4757    /// it. Omitted for `"kept"`.
4758    #[serde(skip_serializing_if = "Option::is_none")]
4759    reason: Option<&'static str>,
4760    /// The first path whose stamp differed. Written only for the reason `"changed"`.
4761    #[serde(skip_serializing_if = "Option::is_none")]
4762    changed_path: Option<String>,
4763}
4764
4765impl HostFactsRecord {
4766    fn of(used: &crate::host_facts::HostFactsUse) -> Self {
4767        use crate::host_facts::HostFactsSource;
4768
4769        let binary = used.binary.display().to_string();
4770        match &used.source {
4771            HostFactsSource::Kept => Self {
4772                binary,
4773                source: "kept",
4774                reason: None,
4775                changed_path: None,
4776            },
4777            HostFactsSource::Derived {
4778                reason,
4779                changed_path,
4780            } => Self {
4781                binary,
4782                source: "derived",
4783                reason: Some(reason.wire_name()),
4784                changed_path: changed_path.as_ref().map(|path| path.display().to_string()),
4785            },
4786        }
4787    }
4788}
4789
4790/// `session_start.control`: the names a capsule's `control:` block declares, never a value.
4791#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4792pub(crate) struct SessionControl {
4793    /// Controllable setting names, as `control.settings` lists them.
4794    pub(crate) settings: Vec<&'static str>,
4795    /// Controller-supplied secret names, as `control.secrets` lists them.
4796    pub(crate) secrets: Vec<String>,
4797    /// Setting names the agent may change, as `control.agent_settings` lists them. Omitted when
4798    /// the block grants the agent nothing.
4799    #[serde(skip_serializing_if = "Vec::is_empty")]
4800    pub(crate) agent_settings: Vec<&'static str>,
4801}
4802
4803/// One element of `session_start.inference_choices`: a driver choice and whether its credential
4804/// resolved at launch. Never a key.
4805#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4806pub(crate) struct SessionInferenceChoice {
4807    pub(crate) name: String,
4808    pub(crate) driver: String,
4809    pub(crate) model: String,
4810    /// Where the driver's gateway credential came from, as `session_start.gateways` names it, or
4811    /// `"none"` for one found nowhere.
4812    pub(crate) credential_source: &'static str,
4813    /// Whether the credential resolved at launch. An injected credential is `true` here and is
4814    /// checked again when a switch asks for it.
4815    pub(crate) available: bool,
4816    /// The choice's own output cap and context window in force, and where each came from.
4817    pub(crate) model_limits: Option<crate::model_limits::ResolvedModelLimits>,
4818}
4819
4820/// One accepted setting or secret change, as [`ResourceTraceAppender::write_control_change`]
4821/// records it. `event_id` is minted by the caller, because the setting it changed carries it
4822/// forward to the `control_applied` that names it.
4823pub(crate) struct ControlChange<'a> {
4824    pub(crate) event_id: String,
4825    /// Who made the change.
4826    pub(crate) principal: crate::control_plane::Principal,
4827    /// The control token's id for a controller's change; `None` for the agent's, which presents
4828    /// no token.
4829    pub(crate) token_id: Option<&'a str>,
4830    /// `"setting"` or `"secret"`.
4831    pub(crate) kind: &'static str,
4832    pub(crate) name: &'a str,
4833    /// `"set"` or `"forget"`.
4834    pub(crate) action: &'static str,
4835    /// A setting's value before and after. Always `None` for a secret.
4836    pub(crate) previous: Option<Value>,
4837    pub(crate) value: Option<Value>,
4838    /// When a setting change takes effect. `None` for a secret.
4839    pub(crate) applies_from: Option<&'static str>,
4840    /// For a secret `set`: whether it replaced a held value.
4841    pub(crate) replaced: Option<bool>,
4842}
4843
4844/// `control_change`: a controller changed a setting or a secret. A secret's line names it and
4845/// carries nothing of its value.
4846#[derive(Serialize)]
4847struct ControlChangeEvent<'a> {
4848    event_type: &'static str,
4849    event_id: String,
4850    parent_id: Option<String>,
4851    session_id: String,
4852    timestamp: u64,
4853    principal: &'static str,
4854    #[serde(skip_serializing_if = "Option::is_none")]
4855    token_id: Option<&'a str>,
4856    kind: &'static str,
4857    name: &'a str,
4858    action: &'static str,
4859    #[serde(skip_serializing_if = "Option::is_none")]
4860    previous: Option<Value>,
4861    #[serde(skip_serializing_if = "Option::is_none")]
4862    value: Option<Value>,
4863    #[serde(skip_serializing_if = "Option::is_none")]
4864    applies_from: Option<&'static str>,
4865    #[serde(skip_serializing_if = "Option::is_none")]
4866    replaced: Option<bool>,
4867}
4868
4869/// `control_refused`: the control plane refused a request, or the runtime refused the agent's
4870/// change. `kind`, `name` and `token_id` are written only once a controller's request
4871/// authenticated, so a `401` names nothing; the agent's refusal names its setting and no token.
4872#[derive(Serialize)]
4873struct ControlRefusedEvent<'a> {
4874    event_type: &'static str,
4875    event_id: String,
4876    parent_id: Option<String>,
4877    session_id: String,
4878    timestamp: u64,
4879    principal: &'static str,
4880    status: u16,
4881    reason: &'static str,
4882    #[serde(skip_serializing_if = "Option::is_none")]
4883    kind: Option<&'static str>,
4884    #[serde(skip_serializing_if = "Option::is_none")]
4885    name: Option<&'a str>,
4886    #[serde(skip_serializing_if = "Option::is_none")]
4887    token_id: Option<&'a str>,
4888}
4889
4890/// `control_applied`: the first inference call to use a setting value a controller set.
4891#[derive(Serialize)]
4892struct ControlAppliedEvent<'a> {
4893    event_type: &'static str,
4894    event_id: String,
4895    parent_id: Option<String>,
4896    session_id: String,
4897    timestamp: u64,
4898    turn: u32,
4899    task_id: Option<String>,
4900    name: &'a str,
4901    value: Value,
4902    change_id: &'a str,
4903}
4904
4905/// `tools_refreshed`: the first inference call to send a tool array rebuilt after an install.
4906#[derive(Serialize)]
4907struct ToolsRefreshedEvent<'a> {
4908    event_type: &'static str,
4909    event_id: String,
4910    parent_id: Option<String>,
4911    session_id: String,
4912    timestamp: u64,
4913    turn: u32,
4914    task_id: Option<String>,
4915    trigger: &'a str,
4916    added: &'a [String],
4917    removed: &'a [String],
4918    tools: &'a [String],
4919}
4920
4921/// `gateway_credential`: the `inference_credential` event for the credential of any artifact other
4922/// than the configured inference driver, with the artifact's name.
4923#[derive(Serialize)]
4924struct GatewayCredentialEvent<'a> {
4925    event_type: &'static str,
4926    event_id: String,
4927    parent_id: Option<String>,
4928    session_id: String,
4929    timestamp: u64,
4930    artifact: &'a str,
4931    source: &'static str,
4932    #[serde(skip_serializing_if = "Option::is_none")]
4933    credential: Option<&'a str>,
4934    change: &'static str,
4935    #[serde(skip_serializing_if = "Option::is_none")]
4936    trigger: Option<&'static str>,
4937    #[serde(skip_serializing_if = "Option::is_none")]
4938    status: Option<u16>,
4939    #[serde(skip_serializing_if = "Option::is_none")]
4940    retried: Option<bool>,
4941    #[serde(skip_serializing_if = "Option::is_none")]
4942    reason: Option<&'static str>,
4943}
4944
4945/// `inference_credential`: a rotation, rejection or unreadable source for the session's inference
4946/// credential. `trigger` is written only with `change: "rotated"`, `status` and `retried` only with
4947/// `"rejected"`, `reason` only with `"unreadable"`, and `credential` whenever the manifest named one.
4948#[derive(Serialize)]
4949struct InferenceCredentialEvent<'a> {
4950    event_type: &'static str,
4951    event_id: String,
4952    parent_id: Option<String>,
4953    session_id: String,
4954    timestamp: u64,
4955    source: &'static str,
4956    #[serde(skip_serializing_if = "Option::is_none")]
4957    credential: Option<&'a str>,
4958    change: &'static str,
4959    #[serde(skip_serializing_if = "Option::is_none")]
4960    trigger: Option<&'static str>,
4961    #[serde(skip_serializing_if = "Option::is_none")]
4962    status: Option<u16>,
4963    #[serde(skip_serializing_if = "Option::is_none")]
4964    retried: Option<bool>,
4965    #[serde(skip_serializing_if = "Option::is_none")]
4966    reason: Option<&'static str>,
4967}
4968
4969/// A credential change as the `change`, `trigger`, `status`, `retried` and `reason` fields of an
4970/// `inference_credential` or `gateway_credential` event.
4971#[allow(clippy::type_complexity)]
4972fn credential_change_fields(
4973    change: CredentialChange,
4974) -> (
4975    &'static str,
4976    Option<&'static str>,
4977    Option<u16>,
4978    Option<bool>,
4979    Option<&'static str>,
4980) {
4981    match change {
4982        CredentialChange::Rotated { trigger } => ("rotated", Some(trigger), None, None, None),
4983        CredentialChange::Rejected { status, retried } => {
4984            ("rejected", None, Some(status), Some(retried), None)
4985        }
4986        CredentialChange::Unreadable { reason } => ("unreadable", None, None, None, Some(reason)),
4987    }
4988}
4989
4990// ── Resource-plane appender ───────────────────────────────────────────────────
4991
4992/// A second appender to the session's `trace.jsonl`, used by the resource plane, by the peer
4993/// plane's listener, and by the peer-handoff tools in the agent loop.
4994///
4995/// [`TraceWriter`] is `&mut`-owned by the agent loop and cannot be shared, and a resource-plane
4996/// event — a gateway reading a finished-but-alive capsule — can happen after `session_end` has
4997/// already been written. So the plane gets its own appender rather than a borrow of the loop's,
4998/// and every event is written at the moment of the request rather than deferred to a task
4999/// boundary: a denied read that only surfaced at the next task end would be a record of the wrong
5000/// thing at the wrong time.
5001///
5002/// Interleaving is safe because every appender of the file writes through one shared
5003/// [`crate::record_seal::InlineChain`], which writes each line whole, chained to the one before.
5004pub struct ResourceTraceAppender {
5005    /// The trace's chain, shared with [`TraceWriter`]; never held across an `await`.
5006    chain: Arc<crate::record_seal::InlineChain>,
5007    /// Serializes `admit` with the line it admits.
5008    admit: std::sync::Mutex<()>,
5009    session_id: String,
5010    /// The session node every line this appender writes hangs off — [`TraceWriter`]'s own
5011    /// [`TraceWriter::session_event_id`], handed over at open time. The plane has no turn or
5012    /// task of its own: a read served after `session_end` belongs to the launch, not to
5013    /// whatever task happened to run last.
5014    session_event_id: String,
5015}
5016
5017impl ResourceTraceAppender {
5018    pub async fn open(
5019        records_root: &WorkdirRoot,
5020        session_id: String,
5021        session_event_id: String,
5022    ) -> std::io::Result<Self> {
5023        let chain = crate::record_seal::InlineChain::open(records_root, TRACE_FILE_NAME)?;
5024        Ok(Self {
5025            chain,
5026            admit: std::sync::Mutex::new(()),
5027            session_id,
5028            session_event_id,
5029        })
5030    }
5031
5032    /// Records one `list`. Failures are swallowed: a trace that cannot be written must not turn
5033    /// a served read into an error, and the alternative — refusing the read — would let anyone
5034    /// who can fill the disk take the plane down.
5035    #[allow(clippy::too_many_arguments)]
5036    pub(crate) async fn write_resource_list(
5037        &self,
5038        root: &str,
5039        entry_count: usize,
5040        total_bytes: u64,
5041        generation: u64,
5042        containment_achieved: ContainmentClass,
5043        outcome: &str,
5044        reason: Option<String>,
5045    ) {
5046        let event = ResourceListEvent {
5047            event_type: "resource_list",
5048            event_id: new_event_id(),
5049            parent_id: Some(self.session_event_id.clone()),
5050            session_id: self.session_id.clone(),
5051            timestamp: timestamp_ms(),
5052            root: root.to_string(),
5053            entry_count,
5054            total_bytes,
5055            generation,
5056            containment_achieved,
5057            outcome: outcome.to_string(),
5058            reason,
5059        };
5060        self.append(&event).await;
5061    }
5062
5063    /// Records one `read`, served or refused. `bytes` and `sha256` are `None` on every
5064    /// non-`ok` outcome — a refusal must not carry a hash of something it did not serve.
5065    #[allow(clippy::too_many_arguments)]
5066    pub(crate) async fn write_resource_read(
5067        &self,
5068        path: &str,
5069        outcome: &str,
5070        bytes: Option<u64>,
5071        sha256: Option<String>,
5072        generation: u64,
5073        containment_achieved: ContainmentClass,
5074        reason: Option<String>,
5075    ) {
5076        let event = ResourceReadEvent {
5077            event_type: "resource_read",
5078            event_id: new_event_id(),
5079            parent_id: Some(self.session_event_id.clone()),
5080            session_id: self.session_id.clone(),
5081            timestamp: timestamp_ms(),
5082            path: path.to_string(),
5083            outcome: outcome.to_string(),
5084            bytes,
5085            sha256,
5086            generation,
5087            containment_achieved,
5088            reason,
5089        };
5090        self.append(&event).await;
5091    }
5092
5093    /// Records `formation_ended`, hung off the session node. Failures are swallowed: a member
5094    /// whose trace cannot be written still winds down.
5095    pub(crate) async fn write_formation_ended(&self, formation_id: &crate::FormationId) {
5096        let event = FormationEndedEvent {
5097            event_type: "formation_ended",
5098            event_id: new_event_id(),
5099            parent_id: Some(self.session_event_id.clone()),
5100            session_id: self.session_id.clone(),
5101            timestamp: timestamp_ms(),
5102            formation_id: formation_id.as_str().to_string(),
5103        };
5104        self.append(&event).await;
5105    }
5106
5107    /// Records one `session-status` call, hung off the session node. Failures are swallowed: the
5108    /// call is answered whether or not its record lands.
5109    pub(crate) async fn write_session_status(
5110        &self,
5111        task_id: Option<String>,
5112        status: &crate::session_status::StatusReport,
5113    ) {
5114        let event = SessionStatusEvent {
5115            event_type: "session_status",
5116            event_id: new_event_id(),
5117            parent_id: Some(self.session_event_id.clone()),
5118            session_id: self.session_id.clone(),
5119            timestamp: timestamp_ms(),
5120            task_id,
5121            status,
5122        };
5123        self.append(&event).await;
5124    }
5125
5126    /// Records one answered `background-command` call, hung off the session node. Failures are
5127    /// swallowed: the call is answered whether or not its record lands.
5128    #[allow(clippy::too_many_arguments)]
5129    pub(crate) async fn write_background_command(
5130        &self,
5131        task_id: Option<String>,
5132        work_id: &str,
5133        operation: &'static str,
5134        state: &'static str,
5135        exit_code: Option<i32>,
5136        waited_ms: Option<u64>,
5137        output_bytes: u64,
5138    ) {
5139        let event = BackgroundCommandEvent {
5140            event_type: "background_command",
5141            event_id: new_event_id(),
5142            parent_id: Some(self.session_event_id.clone()),
5143            session_id: self.session_id.clone(),
5144            timestamp: timestamp_ms(),
5145            task_id,
5146            work_id,
5147            operation,
5148            state,
5149            exit_code,
5150            waited_ms,
5151            output_bytes,
5152        };
5153        self.append(&event).await;
5154    }
5155
5156    /// Records a program a WASM tool started through `murmur:process`, hung off the session node.
5157    /// Failures are swallowed, as every line this appender writes.
5158    pub(crate) async fn write_process_start(&self, line: ProcessStartLine<'_>) {
5159        let event = ProcessStartEvent {
5160            event_type: "process_start",
5161            event_id: new_event_id(),
5162            parent_id: Some(self.session_event_id.clone()),
5163            session_id: self.session_id.clone(),
5164            timestamp: timestamp_ms(),
5165            line,
5166        };
5167        self.append(&event).await;
5168    }
5169
5170    /// Records the exit of a program a WASM tool started through `murmur:process`, hung off the
5171    /// session node.
5172    pub(crate) async fn write_process_exit(&self, line: ProcessExitLine<'_>) {
5173        let event = ProcessExitEvent {
5174            event_type: "process_exit",
5175            event_id: new_event_id(),
5176            parent_id: Some(self.session_event_id.clone()),
5177            session_id: self.session_id.clone(),
5178            timestamp: timestamp_ms(),
5179            line,
5180        };
5181        self.append(&event).await;
5182    }
5183
5184    /// Records a `murmur:process` `run`, `start` or `connect` a rule refused, hung off the session
5185    /// node.
5186    pub(crate) async fn write_process_refused(&self, line: ProcessRefusedLine<'_>) {
5187        let event = ProcessRefusedEvent {
5188            event_type: "process_refused",
5189            event_id: new_event_id(),
5190            parent_id: Some(self.session_event_id.clone()),
5191            session_id: self.session_id.clone(),
5192            timestamp: timestamp_ms(),
5193            line,
5194        };
5195        self.append(&event).await;
5196    }
5197
5198    /// Records a detached command's completion that a `background-command` call just returned to
5199    /// the agent, hung off the session node with no `completion_task_id`. Written after that call's
5200    /// `background_command` line. Failures are swallowed, as that line's are.
5201    pub(crate) async fn write_shell_completed(
5202        &self,
5203        completion: &crate::detached::DetachedCompletion,
5204    ) {
5205        let event = ShellCompletedEvent::new(
5206            self.session_id.clone(),
5207            Some(self.session_event_id.clone()),
5208            completion,
5209            crate::detached::Delivery::BackgroundCommand,
5210            None,
5211        );
5212        self.append(&event).await;
5213    }
5214
5215    /// Records the deadline notice `tool_name`'s result carried, hung off the session node.
5216    pub(crate) async fn write_deadline_notice(
5217        &self,
5218        task_id: Option<String>,
5219        tool_name: &str,
5220        notice: &crate::session_status::DeadlineNotice,
5221    ) {
5222        let event = DeadlineNoticeEvent {
5223            event_type: "deadline_notice",
5224            event_id: new_event_id(),
5225            parent_id: Some(self.session_event_id.clone()),
5226            session_id: self.session_id.clone(),
5227            timestamp: timestamp_ms(),
5228            task_id,
5229            tool_name,
5230            deadline_at: &notice.at,
5231            left_secs: notice.left_secs,
5232            notice_secs: notice.notice_secs,
5233        };
5234        self.append(&event).await;
5235    }
5236
5237    /// Records `deadline_reached`, hung off the session node. Failures are swallowed: a session
5238    /// whose trace cannot be written still ends at its deadline.
5239    pub(crate) async fn write_deadline_reached(
5240        &self,
5241        deadline: &crate::session_status::SessionDeadline,
5242    ) {
5243        let event = DeadlineReachedEvent {
5244            event_type: "deadline_reached",
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            deadline_at: deadline.at_rfc3339(),
5250            deadline_secs: deadline.secs(),
5251            source: deadline.source().as_str(),
5252        };
5253        self.append(&event).await;
5254    }
5255
5256    /// Records `spawner_ended`, hung off the session node, naming the session that spawned this
5257    /// one and the delegation it was spawned under. Failures are swallowed: a child whose trace
5258    /// cannot be written still winds down.
5259    pub(crate) async fn write_spawner_ended(
5260        &self,
5261        spawned_by: Option<&str>,
5262        delegation_id: Option<&str>,
5263    ) {
5264        let event = SpawnerEndedEvent {
5265            event_type: "spawner_ended",
5266            event_id: new_event_id(),
5267            parent_id: Some(self.session_event_id.clone()),
5268            session_id: self.session_id.clone(),
5269            timestamp: timestamp_ms(),
5270            spawned_by: spawned_by.map(str::to_string),
5271            delegation_id: delegation_id.map(str::to_string),
5272        };
5273        self.append(&event).await;
5274    }
5275
5276    /// Records one mint. `handle_id` and `expires_at_ms` are `None` on every non-`ok` outcome —
5277    /// a refused mint produced no token, and an audit record must not imply one exists.
5278    #[allow(clippy::too_many_arguments)]
5279    pub(crate) async fn write_peer_handle_mint(
5280        &self,
5281        handle_id: Option<String>,
5282        path: &str,
5283        audience: &str,
5284        expires_at_ms: Option<u64>,
5285        outcome: &str,
5286        reason: Option<String>,
5287    ) {
5288        let event = PeerHandleMintEvent {
5289            event_type: "peer_handle_mint",
5290            event_id: new_event_id(),
5291            parent_id: Some(self.session_event_id.clone()),
5292            session_id: self.session_id.clone(),
5293            timestamp: timestamp_ms(),
5294            handle_id,
5295            path: path.to_string(),
5296            audience: audience.to_string(),
5297            expires_at_ms,
5298            outcome: outcome.to_string(),
5299            reason,
5300        };
5301        self.append(&event).await;
5302    }
5303
5304    /// Records one redeem, served or refused. The token itself is never written — only its
5305    /// `handle_id`.
5306    #[allow(clippy::too_many_arguments)]
5307    pub(crate) async fn write_peer_handle_redeem(
5308        &self,
5309        handle_id: &str,
5310        path: Option<String>,
5311        generation: u64,
5312        audience_asserted: Option<String>,
5313        bytes: Option<u64>,
5314        sha256: Option<String>,
5315        outcome: &str,
5316        reason: Option<String>,
5317    ) {
5318        let event = PeerHandleRedeemEvent {
5319            event_type: "peer_handle_redeem",
5320            event_id: new_event_id(),
5321            parent_id: Some(self.session_event_id.clone()),
5322            session_id: self.session_id.clone(),
5323            timestamp: timestamp_ms(),
5324            handle_id: handle_id.to_string(),
5325            path,
5326            generation,
5327            audience_asserted,
5328            bytes,
5329            sha256,
5330            outcome: outcome.to_string(),
5331            reason,
5332        };
5333        self.append(&event).await;
5334    }
5335
5336    /// Records one fetch on the ingesting side, served or refused.
5337    #[allow(clippy::too_many_arguments)]
5338    pub(crate) async fn write_peer_file_fetch(
5339        &self,
5340        peer: &str,
5341        handle_id: &str,
5342        stored_path: Option<String>,
5343        bytes: Option<u64>,
5344        sha256: Option<String>,
5345        outcome: &str,
5346        reason: Option<String>,
5347    ) {
5348        let event = PeerFileFetchEvent {
5349            event_type: "peer_file_fetch",
5350            event_id: new_event_id(),
5351            parent_id: Some(self.session_event_id.clone()),
5352            session_id: self.session_id.clone(),
5353            timestamp: timestamp_ms(),
5354            peer: peer.to_string(),
5355            handle_id: handle_id.to_string(),
5356            stored_path,
5357            bytes,
5358            sha256,
5359            outcome: outcome.to_string(),
5360            reason,
5361        };
5362        self.append(&event).await;
5363    }
5364
5365    /// Records one launched child, while its delegation is still running.
5366    ///
5367    /// Every field is known by the time the child has reported its session id, so none is
5368    /// optional: a delegation with nothing to say here was never launched and writes no line.
5369    #[allow(clippy::too_many_arguments)]
5370    pub(crate) async fn write_delegation_start(
5371        &self,
5372        delegation_id: &str,
5373        capsule: &str,
5374        version: &str,
5375        child_session_id: &str,
5376        child_workdir: &str,
5377        child_records_dir: &str,
5378        workdir_snapshot: Option<&crate::workdir_snapshot::WorkdirSnapshot>,
5379    ) {
5380        let event = DelegationStartEvent::new(
5381            &self.session_id,
5382            &self.session_event_id,
5383            delegation_id,
5384            capsule,
5385            version,
5386            child_session_id,
5387            child_workdir,
5388            child_records_dir,
5389            workdir_snapshot,
5390        );
5391        self.append(&event).await;
5392    }
5393
5394    /// Records one delegation ending. `delegation_id` and `child_session_id` are `None` when no
5395    /// child was launched — a delegation that never started names no id, and an audit record must
5396    /// not imply one exists.
5397    #[allow(clippy::too_many_arguments)]
5398    pub(crate) async fn write_delegation(
5399        &self,
5400        capsule: &str,
5401        version: &str,
5402        delegation_id: Option<String>,
5403        child_session_id: Option<String>,
5404        duration_ms: u64,
5405        outcome: &str,
5406        reason: Option<String>,
5407    ) {
5408        let event = DelegationEvent::new(
5409            &self.session_id,
5410            &self.session_event_id,
5411            capsule,
5412            version,
5413            delegation_id,
5414            child_session_id,
5415            duration_ms,
5416            outcome,
5417            reason,
5418        );
5419        self.append(&event).await;
5420    }
5421
5422    /// Records a `call-member` call whose callee now holds the task.
5423    pub(crate) async fn write_member_call_start(
5424        &self,
5425        task_id: &str,
5426        call_id: &str,
5427        member: &str,
5428        member_task_id: &str,
5429    ) {
5430        let event = self.member_call_start(task_id, call_id, member, member_task_id);
5431        self.append(&event).await;
5432    }
5433
5434    /// [`Self::write_member_call_start`] when `admit`, run with the trace held, returns `true`;
5435    /// returns what `admit` did. No other line lands between `admit` and this one.
5436    pub(crate) async fn write_member_call_start_if(
5437        &self,
5438        task_id: &str,
5439        call_id: &str,
5440        member: &str,
5441        member_task_id: &str,
5442        admit: impl FnOnce() -> bool,
5443    ) -> bool {
5444        let event = self.member_call_start(task_id, call_id, member, member_task_id);
5445        self.append_if(&event, admit).await
5446    }
5447
5448    fn member_call_start(
5449        &self,
5450        task_id: &str,
5451        call_id: &str,
5452        member: &str,
5453        member_task_id: &str,
5454    ) -> MemberCallStartEvent {
5455        MemberCallStartEvent {
5456            event_type: "member_call_start",
5457            event_id: new_event_id(),
5458            parent_id: Some(self.session_event_id.clone()),
5459            session_id: self.session_id.clone(),
5460            timestamp: timestamp_ms(),
5461            task_id: task_id.to_string(),
5462            call_id: call_id.to_string(),
5463            member: member.to_string(),
5464            member_task_id: member_task_id.to_string(),
5465        }
5466    }
5467
5468    /// Records the `offer`th offer of a `call-member` call turned away busy, `waited_ms` after the
5469    /// call was made, with the door's status `message`.
5470    pub(crate) async fn write_member_call_busy(
5471        &self,
5472        task_id: &str,
5473        call_id: &str,
5474        member: &str,
5475        offer: u32,
5476        waited_ms: u64,
5477        message: &str,
5478    ) {
5479        let event = MemberCallBusyEvent {
5480            event_type: "member_call_busy",
5481            event_id: new_event_id(),
5482            parent_id: Some(self.session_event_id.clone()),
5483            session_id: self.session_id.clone(),
5484            timestamp: timestamp_ms(),
5485            task_id: task_id.to_string(),
5486            call_id: call_id.to_string(),
5487            member: member.to_string(),
5488            offer,
5489            waited_ms,
5490            message: message.to_string(),
5491        };
5492        self.append(&event).await;
5493    }
5494
5495    /// Records one `call-member` call's ending, and whether its outcome reached the calling task.
5496    pub(crate) async fn write_member_call(
5497        &self,
5498        task_id: &str,
5499        outcome: &crate::member_call::MemberCallOutcome,
5500        delivered: bool,
5501    ) {
5502        let event = MemberCallEvent {
5503            event_type: "member_call",
5504            event_id: new_event_id(),
5505            parent_id: Some(self.session_event_id.clone()),
5506            session_id: self.session_id.clone(),
5507            timestamp: timestamp_ms(),
5508            task_id: task_id.to_string(),
5509            call_id: outcome.call_id.clone(),
5510            member: outcome.member.clone(),
5511            member_task_id: outcome.member_task_id.clone(),
5512            status: outcome.status.as_str().to_string(),
5513            duration_ms: outcome.duration_ms,
5514            output: outcome.output.clone(),
5515            truncated: outcome.truncated,
5516            delivered,
5517            no_answer_below: outcome
5518                .below
5519                .iter()
5520                .map(|(member, status)| NoAnswerBelow {
5521                    member: member.clone(),
5522                    status: status.as_str().to_string(),
5523                })
5524                .collect(),
5525        };
5526        self.append(&event).await;
5527    }
5528
5529    /// Records one change to the session's inference credential. Carries where the credential
5530    /// comes from and what happened to it, never the value.
5531    pub(crate) async fn write_inference_credential(
5532        &self,
5533        source: &'static str,
5534        credential: Option<&str>,
5535        change: CredentialChange,
5536    ) {
5537        let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
5538        let event = InferenceCredentialEvent {
5539            event_type: "inference_credential",
5540            event_id: new_event_id(),
5541            parent_id: Some(self.session_event_id.clone()),
5542            session_id: self.session_id.clone(),
5543            timestamp: timestamp_ms(),
5544            source,
5545            credential,
5546            change: change_name,
5547            trigger,
5548            status,
5549            retried,
5550            reason,
5551        };
5552        self.append(&event).await;
5553    }
5554
5555    /// Writes a `gateway_credential` event for `artifact`'s credential: the fields
5556    /// [`Self::write_inference_credential`] writes, plus `artifact`.
5557    pub(crate) async fn write_gateway_credential(
5558        &self,
5559        artifact: &str,
5560        source: &'static str,
5561        credential: Option<&str>,
5562        change: CredentialChange,
5563    ) {
5564        let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
5565        let event = GatewayCredentialEvent {
5566            event_type: "gateway_credential",
5567            event_id: new_event_id(),
5568            parent_id: Some(self.session_event_id.clone()),
5569            session_id: self.session_id.clone(),
5570            timestamp: timestamp_ms(),
5571            artifact,
5572            source,
5573            credential,
5574            change: change_name,
5575            trigger,
5576            status,
5577            retried,
5578            reason,
5579        };
5580        self.append(&event).await;
5581    }
5582
5583    /// Records one accepted control-plane change, under the `event_id` the caller minted.
5584    pub(crate) async fn write_control_change(&self, change: ControlChange<'_>) {
5585        let event = ControlChangeEvent {
5586            event_type: "control_change",
5587            event_id: change.event_id,
5588            parent_id: Some(self.session_event_id.clone()),
5589            session_id: self.session_id.clone(),
5590            timestamp: timestamp_ms(),
5591            principal: change.principal.as_str(),
5592            token_id: change.token_id,
5593            kind: change.kind,
5594            name: change.name,
5595            action: change.action,
5596            previous: change.previous,
5597            value: change.value,
5598            applies_from: change.applies_from,
5599            replaced: change.replaced,
5600        };
5601        self.append(&event).await;
5602    }
5603
5604    /// Records one refused change. `kind`, `name` and `token_id` are `None` for a controller's
5605    /// request that did not authenticate.
5606    pub(crate) async fn write_control_refused(
5607        &self,
5608        principal: crate::control_plane::Principal,
5609        status: u16,
5610        reason: &'static str,
5611        kind: Option<&'static str>,
5612        name: Option<&str>,
5613        token_id: Option<&str>,
5614    ) {
5615        let event = ControlRefusedEvent {
5616            event_type: "control_refused",
5617            event_id: new_event_id(),
5618            parent_id: Some(self.session_event_id.clone()),
5619            session_id: self.session_id.clone(),
5620            timestamp: timestamp_ms(),
5621            principal: principal.as_str(),
5622            status,
5623            reason,
5624            kind,
5625            name,
5626            token_id,
5627        };
5628        self.append(&event).await;
5629    }
5630
5631    async fn append(&self, event: &impl Serialize) {
5632        self.append_if(event, || true).await;
5633    }
5634
5635    /// [`Self::append`], for a test that needs a resource-plane line without a resource.
5636    #[cfg(test)]
5637    pub(crate) async fn append_for_test(&self, event: &impl Serialize) {
5638        self.append(event).await;
5639    }
5640
5641    /// [`Self::append`] when `admit`, run with the trace held, returns `true`; returns what
5642    /// `admit` did. No other line lands between `admit` and this one.
5643    async fn append_if(&self, event: &impl Serialize, admit: impl FnOnce() -> bool) -> bool {
5644        let Ok(line) = serde_json::to_string(event) else {
5645            return admit();
5646        };
5647        let _held = self.admit.lock().unwrap_or_else(|p| p.into_inner());
5648        if !admit() {
5649            return false;
5650        }
5651        let _ = self.chain.append_json(&line);
5652        true
5653    }
5654}
5655
5656// -- Prior-session appender ---------------------------------------------------
5657
5658/// An `O_APPEND` handle to a *previous* session's `trace.jsonl`, opened by a resuming launch to
5659/// account for demoted commands that session left unreported.
5660///
5661/// The marker goes in the file that holds the unmatched `shell_detached` line rather than in the
5662/// resuming session's own trace, which keeps clearing O(1) instead of a scan of every sibling
5663/// session, and means [`crate::retention::prune_sessions`] removes a marker and the work it
5664/// accounts for together.
5665///
5666/// Writing to another session's trace is safe because that session's writer is dead — that is the
5667/// precondition for there being anything unaccounted — and because it writes through the file's
5668/// [`crate::record_seal::InlineChain`], which takes the file's `flock` and writes each line whole.
5669pub(crate) struct PriorSessionTraceAppender {
5670    /// The prior trace's chain: a torn last line is terminated, and its hash chained, by the
5671    /// first append.
5672    chain: Arc<crate::record_seal::InlineChain>,
5673    /// The prior session's records directory and its trace's name, which the reseal is written
5674    /// beside.
5675    trace_dir: WorkdirRoot,
5676    trace: String,
5677    /// The prior session's id, which is what its own lines carry.
5678    session_id: String,
5679    /// That session's `session_start` node, read back from the same file.
5680    session_event_id: Option<String>,
5681}
5682
5683impl PriorSessionTraceAppender {
5684    /// `trace` is the resumed trace's file name in `trace_dir`, the directory
5685    /// [`crate::ResumeRequest::trace_dir`] names. Errors when it cannot be read or opened for
5686    /// appending, which the caller reports rather than reporting work it could not mark.
5687    pub(crate) async fn open(
5688        trace_dir: &WorkdirRoot,
5689        trace: &str,
5690        session_id: String,
5691        session_event_id: Option<String>,
5692    ) -> std::io::Result<Self> {
5693        // A missing trace is an error rather than an empty file to create: there is no session
5694        // to account for.
5695        if trace_dir.entry_kind(trace)?.is_none() {
5696            return Err(std::io::Error::from(std::io::ErrorKind::NotFound));
5697        }
5698        let chain = crate::record_seal::InlineChain::open(trace_dir, trace)?;
5699        Ok(Self {
5700            chain,
5701            trace_dir: trace_dir.clone(),
5702            trace: trace.to_string(),
5703            session_id,
5704            session_event_id,
5705        })
5706    }
5707
5708    /// Records one demoted command as lost, and reports whether the line landed.
5709    ///
5710    /// The result is not swallowed: a work id whose marker did not land must be dropped from the
5711    /// report rather than reported unmarked, because an unmarked work id is reported again by
5712    /// every later resume of the same session.
5713    pub(crate) async fn write_shell_lost(
5714        &mut self,
5715        work: &crate::detached::LostWork,
5716        reconciled_by_session: &str,
5717        reconciled_task_id: &str,
5718    ) -> std::io::Result<()> {
5719        let event = ShellLostEvent {
5720            event_type: "shell_lost",
5721            event_id: new_event_id(),
5722            parent_id: self.session_event_id.clone(),
5723            session_id: self.session_id.clone(),
5724            timestamp: timestamp_ms(),
5725            work_id: work.work_id.clone(),
5726            binary: work.binary.clone(),
5727            command: work.command.clone(),
5728            detached_at_ms: work.detached_at_ms,
5729            reconciled_by_session: reconciled_by_session.to_string(),
5730            reconciled_task_id: reconciled_task_id.to_string(),
5731        };
5732        // The chain terminates a torn tail itself, before this line, from the file as it is.
5733        self.chain.append_event(&event)?;
5734        Ok(())
5735    }
5736
5737    /// Seals the prior session's trace again, for that session, once its last `shell_lost` line
5738    /// is written: the seal its own runtime wrote covers fewer lines than the file now holds.
5739    pub(crate) fn reseal(&self) {
5740        if let Err(reason) =
5741            crate::record_seal::seal_inline(&self.trace_dir, &self.trace, &self.session_id, None)
5742        {
5743            crate::runtime_err!(
5744                "[capsule-runtime] could not reseal {} ({reason}); it reads as unsealed",
5745                self.trace_dir.display_path(&self.trace)
5746            );
5747        }
5748    }
5749}
5750
5751pub(crate) fn timestamp_ms() -> u64 {
5752    SystemTime::now()
5753        .duration_since(UNIX_EPOCH)
5754        .unwrap_or_default()
5755        .as_millis() as u64
5756}
5757
5758// ── Plan-scheduler events ────────────────────────────────────────────────────
5759
5760/// One step of a plan's DAG as authored, written into `plan_start`.
5761///
5762/// The shape is recorded once, up front, so the run stays legible for a step that never ran:
5763/// the trace names every step the plan declared whether or not the scheduler reached it.
5764#[derive(Serialize, Clone)]
5765pub(crate) struct PlanStepShape {
5766    pub(crate) step_id: String,
5767    /// `"tool"`, `"shell"` or `"capsule"`; `"unknown"` for a step whose kind cannot be inferred,
5768    /// which is a step the plan validator refuses.
5769    pub(crate) kind: &'static str,
5770    pub(crate) depends_on: Vec<String>,
5771    /// Whether the step carries an `if`, and so may settle without ever being dispatched.
5772    pub(crate) has_condition: bool,
5773}
5774
5775/// The plan's structure, written once before any step runs. Its `event_id` is the plan node
5776/// every other line of the run hangs off.
5777#[derive(Serialize)]
5778struct PlanStartEvent {
5779    event_type: &'static str,
5780    event_id: String,
5781    parent_id: Option<String>,
5782    session_id: String,
5783    timestamp: u64,
5784    plan_id: String,
5785    step_count: usize,
5786    steps: Vec<PlanStepShape>,
5787}
5788
5789/// One dispatched step, written as the scheduler hands it to a worker.
5790///
5791/// A step that settled without ever being dispatched — an `if` that evaluated false, a
5792/// dependency that never resolved — writes no line here, only a terminal `plan_step`. Joins to
5793/// that line on `(plan_id, step_id)`.
5794#[derive(Serialize)]
5795struct PlanStepStartEvent {
5796    event_type: &'static str,
5797    event_id: String,
5798    parent_id: Option<String>,
5799    session_id: String,
5800    timestamp: u64,
5801    plan_id: String,
5802    step_id: String,
5803    kind: &'static str,
5804    depends_on: Vec<String>,
5805}
5806
5807/// A settled step, written by the scheduler loop after the step's `on_error` policy has been
5808/// applied — which is why the worker thread does not write it: a step that failed under
5809/// `on_error: skip` settles as skipped in the returned report and must read as skipped here.
5810#[derive(Serialize)]
5811struct PlanStepEvent {
5812    event_type: &'static str,
5813    event_id: String,
5814    parent_id: Option<String>,
5815    session_id: String,
5816    timestamp: u64,
5817    plan_id: String,
5818    step_id: String,
5819    kind: &'static str,
5820    status: &'static str,
5821    attempts: u32,
5822    duration_ms: u64,
5823    #[serde(skip_serializing_if = "Option::is_none")]
5824    error: Option<String>,
5825    /// The interpolated input the step dispatched, with peer handle tokens redacted. Tool steps
5826    /// only — a shell step's command and a capsule step's task text are not carried here.
5827    #[serde(skip_serializing_if = "Option::is_none")]
5828    input: Option<Value>,
5829    /// The tool's self-declared `state_effect` for this step, lifted verbatim from
5830    /// `tool-result.metadata`. Absent when the tool declared nothing. Feeds the same
5831    /// redundant-call analysis `tool_call.state_effect` does.
5832    #[serde(skip_serializing_if = "Option::is_none")]
5833    state_effect: Option<String>,
5834    /// The resource this step addressed, as the tool declared it. Opaque and never parsed here.
5835    #[serde(skip_serializing_if = "Option::is_none")]
5836    resource_id: Option<String>,
5837}
5838
5839/// The run's summary, written once as `plan::execute` returns, whatever ended it.
5840#[derive(Serialize)]
5841struct PlanEndEvent {
5842    event_type: &'static str,
5843    event_id: String,
5844    parent_id: Option<String>,
5845    session_id: String,
5846    timestamp: u64,
5847    plan_id: String,
5848    outcome: &'static str,
5849    #[serde(skip_serializing_if = "Option::is_none")]
5850    failed_step: Option<String>,
5851    steps_total: usize,
5852    steps_succeeded: usize,
5853    steps_failed: usize,
5854    steps_skipped: usize,
5855    duration_ms: u64,
5856    /// Why the run ended when the reason was not a step's own failure: a plan that would not
5857    /// parse, a cgroup scope the host refused, a DAG with nothing left to run.
5858    #[serde(skip_serializing_if = "Option::is_none")]
5859    reason: Option<String>,
5860}
5861
5862/// Everything the scheduler loop knows about a settled step, as one argument.
5863pub(crate) struct PlanStepRecord {
5864    pub(crate) plan_id: String,
5865    pub(crate) step_id: String,
5866    pub(crate) kind: &'static str,
5867    /// `"success"`, `"failed"` or `"skipped"` — the status the returned `ExecutionReport` carries
5868    /// for this step, so the two can never disagree.
5869    pub(crate) status: &'static str,
5870    /// How many times the step was dispatched. `0` for a step that settled without dispatch.
5871    pub(crate) attempts: u32,
5872    pub(crate) duration_ms: u64,
5873    pub(crate) error: Option<String>,
5874    pub(crate) input: Option<Value>,
5875    pub(crate) state_effect: Option<String>,
5876    pub(crate) resource_id: Option<String>,
5877}
5878
5879/// Everything the scheduler knows about a finished run, as one argument.
5880pub(crate) struct PlanEndRecord {
5881    pub(crate) plan_id: String,
5882    pub(crate) outcome: &'static str,
5883    pub(crate) failed_step: Option<String>,
5884    pub(crate) steps_total: usize,
5885    pub(crate) steps_succeeded: usize,
5886    pub(crate) steps_failed: usize,
5887    pub(crate) steps_skipped: usize,
5888    pub(crate) duration_ms: u64,
5889    pub(crate) reason: Option<String>,
5890}
5891
5892/// A third `O_APPEND` handle to the session's `trace.jsonl`, held by a plan run.
5893///
5894/// Synchronous where [`ResourceTraceAppender`] is async, because [`crate::plan::execute`] is: it
5895/// runs its ready set on plain threads inside a `thread::scope`, so there is no runtime to await
5896/// on and no `await` for a lock to be held across. Interleaving with [`TraceWriter`] and with
5897/// the resource plane is safe on the same terms they interleave with each other — one complete
5898/// line per `write_all`, then flush, which `O_APPEND` makes atomic against the other writers.
5899///
5900/// Handing one of these to a [`crate::plan::SchedulerContext`] is the whole of the opt-in: a
5901/// context carrying `None` opens no file and writes no line.
5902pub struct PlanTraceAppender {
5903    /// The trace's chain, shared with [`TraceWriter`] and the resource plane.
5904    chain: Arc<crate::record_seal::InlineChain>,
5905    session_id: String,
5906    /// The session node a run's `plan_start` hangs off — [`TraceWriter`]'s own
5907    /// `session_start`, handed over at open time so an appended line names a real parent.
5908    session_event_id: String,
5909}
5910
5911impl PlanTraceAppender {
5912    pub fn open(
5913        records_root: &WorkdirRoot,
5914        session_id: String,
5915        session_event_id: String,
5916    ) -> std::io::Result<Self> {
5917        let chain = crate::record_seal::InlineChain::open(records_root, TRACE_FILE_NAME)?;
5918        Ok(Self {
5919            chain,
5920            session_id,
5921            session_event_id,
5922        })
5923    }
5924
5925    /// Records the plan's DAG and returns the plan node every later line of this run hangs off.
5926    pub(crate) fn write_plan_start(&self, plan_id: &str, steps: Vec<PlanStepShape>) -> String {
5927        let event_id = new_event_id();
5928        let event = PlanStartEvent {
5929            event_type: "plan_start",
5930            event_id: event_id.clone(),
5931            parent_id: Some(self.session_event_id.clone()),
5932            session_id: self.session_id.clone(),
5933            timestamp: timestamp_ms(),
5934            plan_id: plan_id.to_string(),
5935            step_count: steps.len(),
5936            steps,
5937        };
5938        self.append(&event);
5939        event_id
5940    }
5941
5942    pub(crate) fn write_plan_step_start(
5943        &self,
5944        plan_node: &str,
5945        plan_id: &str,
5946        step_id: &str,
5947        kind: &'static str,
5948        depends_on: Vec<String>,
5949    ) {
5950        let event = PlanStepStartEvent {
5951            event_type: "plan_step_start",
5952            event_id: new_event_id(),
5953            parent_id: Some(plan_node.to_string()),
5954            session_id: self.session_id.clone(),
5955            timestamp: timestamp_ms(),
5956            plan_id: plan_id.to_string(),
5957            step_id: step_id.to_string(),
5958            kind,
5959            depends_on,
5960        };
5961        self.append(&event);
5962    }
5963
5964    pub(crate) fn write_plan_step(&self, plan_node: &str, record: PlanStepRecord) {
5965        // On the same terms `TraceWriter::write_tool_call` redacts a tool call's input: a peer
5966        // handle is a credential and this file is durable.
5967        let input = record.input.map(|mut value| {
5968            crate::peer_handoff::redact_handles_in_json(&mut value);
5969            value
5970        });
5971        let event = PlanStepEvent {
5972            event_type: "plan_step",
5973            event_id: new_event_id(),
5974            parent_id: Some(plan_node.to_string()),
5975            session_id: self.session_id.clone(),
5976            timestamp: timestamp_ms(),
5977            plan_id: record.plan_id,
5978            step_id: record.step_id,
5979            kind: record.kind,
5980            status: record.status,
5981            attempts: record.attempts,
5982            duration_ms: record.duration_ms,
5983            error: record.error,
5984            input,
5985            state_effect: record.state_effect,
5986            resource_id: record.resource_id,
5987        };
5988        self.append(&event);
5989    }
5990
5991    /// Records the run's summary. `plan_node` is `None` for a run that never had one — a plan
5992    /// file that would not parse describes no DAG — and the line hangs off the session instead.
5993    pub(crate) fn write_plan_end(&self, plan_node: Option<&str>, record: PlanEndRecord) {
5994        let event = PlanEndEvent {
5995            event_type: "plan_end",
5996            event_id: new_event_id(),
5997            parent_id: Some(
5998                plan_node
5999                    .map(str::to_string)
6000                    .unwrap_or_else(|| self.session_event_id.clone()),
6001            ),
6002            session_id: self.session_id.clone(),
6003            timestamp: timestamp_ms(),
6004            plan_id: record.plan_id,
6005            outcome: record.outcome,
6006            failed_step: record.failed_step,
6007            steps_total: record.steps_total,
6008            steps_succeeded: record.steps_succeeded,
6009            steps_failed: record.steps_failed,
6010            steps_skipped: record.steps_skipped,
6011            duration_ms: record.duration_ms,
6012            reason: record.reason,
6013        };
6014        self.append(&event);
6015    }
6016
6017    /// Records one child a `capsule` step launched, while its delegation is still running.
6018    ///
6019    /// The synchronous twin of [`ResourceTraceAppender::write_delegation_start`]. Both go through
6020    /// [`DelegationStartEvent::new`], so the two lines are identical but for their ids and
6021    /// clocks — held by there being one constructor rather than by a test.
6022    #[allow(clippy::too_many_arguments)]
6023    pub(crate) fn write_delegation_start(
6024        &self,
6025        delegation_id: &str,
6026        capsule: &str,
6027        version: &str,
6028        child_session_id: &str,
6029        child_workdir: &str,
6030        child_records_dir: &str,
6031        workdir_snapshot: Option<&crate::workdir_snapshot::WorkdirSnapshot>,
6032    ) {
6033        let event = DelegationStartEvent::new(
6034            &self.session_id,
6035            &self.session_event_id,
6036            delegation_id,
6037            capsule,
6038            version,
6039            child_session_id,
6040            child_workdir,
6041            child_records_dir,
6042            workdir_snapshot,
6043        );
6044        self.append(&event);
6045    }
6046
6047    /// Records one `capsule` step's delegation ending. `delegation_id` and `child_session_id` are
6048    /// `None` when no child was launched, on the same terms
6049    /// [`ResourceTraceAppender::write_delegation`] passes them.
6050    ///
6051    /// One pair per *attempt*: a step with retries delegates once per attempt, so it writes one
6052    /// of these per attempt. Two `failed` lines and a `completed` one is what happened, not a
6053    /// duplicate of one thing.
6054    #[allow(clippy::too_many_arguments)]
6055    pub(crate) fn write_delegation(
6056        &self,
6057        capsule: &str,
6058        version: &str,
6059        delegation_id: Option<String>,
6060        child_session_id: Option<String>,
6061        duration_ms: u64,
6062        outcome: &str,
6063        reason: Option<String>,
6064    ) {
6065        let event = DelegationEvent::new(
6066            &self.session_id,
6067            &self.session_event_id,
6068            capsule,
6069            version,
6070            delegation_id,
6071            child_session_id,
6072            duration_ms,
6073            outcome,
6074            reason,
6075        );
6076        self.append(&event);
6077    }
6078
6079    /// Writes one complete line and flushes it. Every failure is swallowed, exactly as
6080    /// [`ResourceTraceAppender::append`] swallows one: a trace that cannot be written must never
6081    /// turn a step that ran into a step that failed.
6082    fn append(&self, event: &impl Serialize) {
6083        let _ = self.chain.append_event(event);
6084    }
6085}
6086
6087// ── Unit tests ────────────────────────────────────────────────────────────────
6088
6089#[cfg(test)]
6090mod tests {
6091    use super::*;
6092    use crate::{
6093        sandbox::EnforcementTier, sealed::UsernsGrant, trace_blobs::BLOB_DIR_NAME,
6094        types::CapabilityPolicy,
6095    };
6096    use murmur_artifact::{ArtifactRuntime, InterpreterRuntimeDir, InterpreterRuntimeGrant};
6097    use serde_json::Value;
6098    use std::path::Path;
6099
6100    use crate::origin::TaskOrigin;
6101
6102    /// The class an A2A task carrying no origin claim resolves to.
6103    fn event_provenance() -> TaskProvenance {
6104        TaskProvenance::derive(TaskOrigin::Event, None)
6105    }
6106
6107    /// A [`ScopeReport`] built the way production builds one — through
6108    /// `containment::report_for` with an *explicit* tier, never a live probe, so these
6109    /// tests assert the same values on a Landlock-capable host and a laptop that has none.
6110    fn report_for(
6111        declared: ContainmentClass,
6112        tier: EnforcementTier,
6113        workdir_exec: bool,
6114    ) -> ScopeReport {
6115        crate::containment::report_for(
6116            &CapabilityPolicy {
6117                workdir_exec_allowed: workdir_exec,
6118                ..CapabilityPolicy::default()
6119            },
6120            declared,
6121            tier,
6122            None,
6123            None,
6124            None,
6125            None,
6126            Vec::new(),
6127            Vec::new(),
6128            Vec::new(),
6129            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6130            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(tier, declared)),
6131            crate::cgroup::IoMaxReport::default(),
6132        )
6133    }
6134
6135    fn demotion_info() -> crate::detached::DetachedDispatchInfo {
6136        crate::detached::DetachedDispatchInfo {
6137            work_id: "wrk_0a1b2c3d4e5f6a7b".to_string(),
6138            binary: "/usr/bin/bash".to_string(),
6139            command: "make -j8".to_string(),
6140            grace_ms: Some(1_000),
6141        }
6142    }
6143
6144    /// A demotion whose marker cannot be written stands anyway: the write failure is reported and
6145    /// swallowed, and the session carries on with the command running in the background.
6146    #[tokio::test]
6147    async fn a_demotion_marker_that_cannot_be_written_does_not_fail_the_demotion() {
6148        let dir = tempfile::tempdir().unwrap();
6149        // `/dev/full` opens and fails every write with `ENOSPC`, which is the one way to get a
6150        // trace handle that is valid and unwritable without root. Swapped in under the writer,
6151        // because a link at `trace.jsonl` is refused when the writer opens it.
6152        let mut writer = make_writer(dir.path()).await;
6153        let full = std::fs::OpenOptions::new()
6154            .write(true)
6155            .open("/dev/full")
6156            .unwrap();
6157        writer.chain = crate::record_seal::InlineChain::over_file_for_test(
6158            full,
6159            &WorkdirRoot::open(dir.path()).unwrap(),
6160            TRACE_FILE_NAME,
6161        );
6162        let info = demotion_info();
6163
6164        assert!(
6165            writer
6166                .write_shell_detached(1, &info.work_id, &info.binary, &info.command, info.grace_ms)
6167                .await
6168                .is_err(),
6169            "the sink under this writer must be unwritable for the claim below to mean anything"
6170        );
6171        crate::agent::record_demotion(&mut writer, 1, &info).await;
6172    }
6173
6174    /// The ordinary path writes one `shell_detached` and nothing else.
6175    #[tokio::test]
6176    async fn a_recorded_demotion_writes_only_its_own_marker() {
6177        let dir = tempfile::tempdir().unwrap();
6178        let mut writer = make_writer(dir.path()).await;
6179        crate::agent::record_demotion(&mut writer, 3, &demotion_info()).await;
6180
6181        let events = read_events(dir.path());
6182        assert_eq!(events.len(), 1);
6183        assert_eq!(events[0]["event_type"], "shell_detached");
6184        assert_eq!(events[0]["work_id"], "wrk_0a1b2c3d4e5f6a7b");
6185        assert_eq!(events[0]["turn"], 3);
6186        assert_eq!(events[0]["grace_ms"], 1_000);
6187    }
6188
6189    /// A demotion with no grace in force carries no `grace_ms` key at all.
6190    #[tokio::test]
6191    async fn a_demotion_with_no_grace_writes_no_grace_ms() {
6192        let dir = tempfile::tempdir().unwrap();
6193        let mut writer = make_writer(dir.path()).await;
6194        let info = crate::detached::DetachedDispatchInfo {
6195            grace_ms: None,
6196            ..demotion_info()
6197        };
6198        crate::agent::record_demotion(&mut writer, 3, &info).await;
6199
6200        let events = read_events(dir.path());
6201        assert_eq!(events.len(), 1);
6202        assert_eq!(events[0]["event_type"], "shell_detached");
6203        assert!(events[0].get("grace_ms").is_none(), "{}", events[0]);
6204    }
6205
6206    #[tokio::test]
6207    async fn a_safety_check_event_carries_the_checks_verbatim_and_its_outcome() {
6208        let dir = tempfile::tempdir().unwrap();
6209        let mut writer = make_writer(dir.path()).await;
6210        let checks = serde_json::json!([{"id": "sc_1", "code": "c", "message": "m", "x": 1}]);
6211        writer
6212            .write_safety_check(2, "call_1_0", "computer", &checks, "declined")
6213            .await
6214            .unwrap();
6215
6216        let events = read_events(dir.path());
6217        assert_eq!(events.len(), 1);
6218        assert_eq!(events[0]["event_type"], "safety_check");
6219        assert_eq!(events[0]["turn"], 2);
6220        assert_eq!(events[0]["tool_call_id"], "call_1_0");
6221        assert_eq!(events[0]["tool"], "computer");
6222        assert_eq!(events[0]["checks"], checks);
6223        assert_eq!(events[0]["outcome"], "declined");
6224        assert!(events[0]["event_id"].is_string() && events[0]["session_id"].is_string());
6225    }
6226
6227    #[tokio::test]
6228    async fn an_unrecorded_demotion_names_the_work_id_and_the_write_error() {
6229        let dir = tempfile::tempdir().unwrap();
6230        let mut writer = make_writer(dir.path()).await;
6231        writer
6232            .write_shell_detach_unrecorded(2, "wrk_0a1b", "/usr/bin/bash", "No space left")
6233            .await
6234            .unwrap();
6235
6236        let events = read_events(dir.path());
6237        assert_eq!(events.len(), 1);
6238        assert_eq!(events[0]["event_type"], "shell_detach_unrecorded");
6239        assert_eq!(events[0]["turn"], 2);
6240        assert_eq!(events[0]["work_id"], "wrk_0a1b");
6241        assert_eq!(events[0]["binary"], "/usr/bin/bash");
6242        assert_eq!(events[0]["reason"], "No space left");
6243    }
6244
6245    /// A command still running when the session ended has no exit code, no output file and no
6246    /// byte count, and its line carries none of the three — omitted rather than `null`, so the
6247    /// shape is identical to a line written before those fields existed.
6248    #[tokio::test]
6249    async fn an_abandoned_command_still_running_omits_the_fields_it_cannot_know() {
6250        let dir = tempfile::tempdir().unwrap();
6251        let mut writer = make_writer(dir.path()).await;
6252        writer
6253            .write_shell_abandoned(
6254                "wrk_0a1b2c3d4e5f6a7b",
6255                "/usr/bin/bash",
6256                "sleep 45; echo done",
6257                45_012,
6258                None,
6259            )
6260            .await
6261            .unwrap();
6262
6263        let events = read_events(dir.path());
6264        assert_eq!(events.len(), 1);
6265        let event = &events[0];
6266        assert_eq!(event["event_type"], "shell_abandoned");
6267        assert_eq!(event["work_id"], "wrk_0a1b2c3d4e5f6a7b");
6268        assert_eq!(event["binary"], "/usr/bin/bash");
6269        assert_eq!(event["command"], "sleep 45; echo done");
6270        assert_eq!(event["running_ms"], 45_012);
6271        for absent in ["exit_code", "output_path", "output_bytes"] {
6272            assert!(
6273                event.get(absent).is_none(),
6274                "{absent} must be omitted, not written as null: {event}"
6275            );
6276        }
6277    }
6278
6279    /// A command that finished during teardown carries the exit code, the output path and the
6280    /// byte count that do exist for it, so a trace reader can tell it from one still running
6281    /// without reading anything else.
6282    #[tokio::test]
6283    async fn an_abandoned_command_that_finished_carries_its_result() {
6284        let dir = tempfile::tempdir().unwrap();
6285        let mut writer = make_writer(dir.path()).await;
6286        writer
6287            .write_shell_abandoned(
6288                "wrk_0a1b2c3d4e5f6a7b",
6289                "/usr/bin/bash",
6290                "sleep 2; echo done",
6291                2_014,
6292                Some((
6293                    0,
6294                    "/home/test/.murmur/records/work/ses_1/logs/wrk_0a1b2c3d4e5f6a7b.log",
6295                    91,
6296                )),
6297            )
6298            .await
6299            .unwrap();
6300
6301        let event = &read_events(dir.path())[0];
6302        assert_eq!(event["event_type"], "shell_abandoned");
6303        assert_eq!(event["running_ms"], 2_014);
6304        assert_eq!(event["exit_code"], 0);
6305        assert_eq!(
6306            event["output_path"],
6307            "/home/test/.murmur/records/work/ses_1/logs/wrk_0a1b2c3d4e5f6a7b.log"
6308        );
6309        assert_eq!(event["output_bytes"], 91);
6310    }
6311
6312    async fn make_writer(dir: &std::path::Path) -> TraceWriter {
6313        make_writer_with_opts(dir, TraceCapture::Meta).await
6314    }
6315
6316    async fn make_writer_with_opts(dir: &std::path::Path, capture: TraceCapture) -> TraceWriter {
6317        make_writer_with_prompt(dir, capture, None, false).await
6318    }
6319
6320    async fn make_writer_with_prompt(
6321        dir: &std::path::Path,
6322        capture: TraceCapture,
6323        system_prompt: Option<&str>,
6324        system_prompt_overridden: bool,
6325    ) -> TraceWriter {
6326        TraceWriter::open(
6327            &crate::workdir_fs::WorkdirRoot::open(dir).unwrap(),
6328            "test-session-id".to_string(),
6329            "test-capsule".to_string(),
6330            "1.0.0".to_string(),
6331            "claude-test".to_string(),
6332            vec!["shell".to_string()],
6333            report_for(
6334                ContainmentClass::Advisory,
6335                EnforcementTier::EnvironmentOnly,
6336                false,
6337            ),
6338            capture,
6339            system_prompt.map(str::to_string),
6340            system_prompt_overridden,
6341            None,
6342            None,
6343            None,
6344            None,
6345        )
6346        .await
6347        .unwrap()
6348    }
6349
6350    fn read_events(dir: &std::path::Path) -> Vec<Value> {
6351        let content = std::fs::read_to_string(dir.join("trace.jsonl")).unwrap();
6352        content
6353            .lines()
6354            .filter(|l| !l.is_empty())
6355            .map(|l| serde_json::from_str(l).unwrap())
6356            .collect()
6357    }
6358
6359    #[tokio::test]
6360    async fn session_start_fields() {
6361        let dir = tempfile::tempdir().unwrap();
6362        let mut w = make_writer(dir.path()).await;
6363        w.write_session_start(Some(10), None, vec!["bash".to_string()])
6364            .await
6365            .unwrap();
6366        w.flush().await.unwrap();
6367
6368        let events = read_events(dir.path());
6369        assert_eq!(events.len(), 1);
6370        let e = &events[0];
6371        assert_eq!(e["event_type"], "session_start");
6372        assert_eq!(e["session_id"], "test-session-id");
6373        assert_eq!(e["capsule_name"], "test-capsule");
6374        assert_eq!(e["capsule_version"], "1.0.0");
6375        assert_eq!(e["model"], "claude-test");
6376        assert_eq!(e["max_turns"], 10);
6377        assert_eq!(e["capabilities"], serde_json::json!(["shell"]));
6378        assert_eq!(e["tools_declared"], serde_json::json!(["bash"]));
6379        assert_eq!(e["containment_declared"], "advisory");
6380        assert_eq!(e["containment_achieved"], "advisory");
6381        assert_eq!(e["workdir_exec"], false);
6382        assert!(e["timestamp"].as_u64().unwrap() > 0);
6383        assert_eq!(e["model_limits"], serde_json::Value::Null);
6384    }
6385
6386    /// No turn cap is written as `null`, beside the primary's limits and their sources.
6387    #[tokio::test]
6388    async fn session_start_writes_no_turn_cap_and_the_model_limits() {
6389        let dir = tempfile::tempdir().unwrap();
6390        let mut w = make_writer(dir.path()).await;
6391        let limits = crate::model_limits::ResolvedModelLimits {
6392            max_output_tokens: 65536,
6393            max_output_tokens_source: crate::model_limits::LimitSource::Driver,
6394            context_window: None,
6395            context_window_source: crate::model_limits::LimitSource::None,
6396            max_input_tokens: None,
6397            max_input_tokens_source: crate::model_limits::LimitSource::None,
6398            input_budget: None,
6399            input_budget_basis: crate::model_limits::BudgetBasis::None,
6400        };
6401        w.write_session_start(None, Some(limits), Vec::new())
6402            .await
6403            .unwrap();
6404        w.flush().await.unwrap();
6405
6406        let e = &read_events(dir.path())[0];
6407        assert!(e.get("max_turns").is_some_and(serde_json::Value::is_null));
6408        assert_eq!(
6409            e["model_limits"],
6410            serde_json::json!({
6411                "max_output_tokens": 65536,
6412                "max_output_tokens_source": "driver",
6413                "context_window": null,
6414                "context_window_source": "none",
6415                "max_input_tokens": null,
6416                "max_input_tokens_source": "none",
6417                "input_budget": null,
6418                "input_budget_basis": "none",
6419            })
6420        );
6421    }
6422
6423    /// A member's formation is on the trace's first line, which is all a reader grouping sessions
6424    /// reads; a session in no formation writes no key at all.
6425    #[tokio::test]
6426    async fn session_start_is_the_first_line_and_carries_a_members_formation() {
6427        let formation = FormationId::mint();
6428        for member in [Some(&formation), None] {
6429            let dir = tempfile::tempdir().unwrap();
6430            let mut w = make_writer(dir.path()).await;
6431            w.set_formation_id(member);
6432            w.write_session_start(Some(10), None, Vec::new())
6433                .await
6434                .unwrap();
6435            w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
6436                .await
6437                .unwrap();
6438            w.flush().await.unwrap();
6439
6440            let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
6441            let first_line = content.lines().next().unwrap();
6442            let first: Value = serde_json::from_str(first_line).unwrap();
6443            assert_eq!(first["event_type"], "session_start");
6444            match member {
6445                Some(id) => {
6446                    assert_eq!(first["formation_id"], id.as_str());
6447                    assert!(
6448                        first_line.contains(&format!("\"formation_id\":\"{id}\"")),
6449                        "{first_line}"
6450                    );
6451                }
6452                None => assert!(!first_line.contains("formation_id"), "{first_line}"),
6453            }
6454            // Only the session frame carries it.
6455            assert_eq!(
6456                content.matches("formation_id").count(),
6457                usize::from(member.is_some())
6458            );
6459        }
6460    }
6461
6462    /// A member's `session_start` names it and its callees, `[]` for none, and only a member's
6463    /// does; `a2a_task_received.caller_member` is present only for a task a formation token let
6464    /// in.
6465    #[tokio::test]
6466    async fn a_members_trace_names_it_its_callees_and_its_callers() {
6467        let authority = crate::formation_credentials::FormationAuthority::for_test();
6468        let cases = [
6469            (Some(("coder", vec!["reviewer"])), Some("planner")),
6470            (Some(("reviewer", vec![])), None),
6471            (None, None),
6472        ];
6473        for (member, caller) in cases {
6474            let member = member.map(|(name, callees)| {
6475                crate::formation_credentials::FormationMember::from_bundle(
6476                    authority.member_bundle(name, &callees),
6477                )
6478            });
6479            let dir = tempfile::tempdir().unwrap();
6480            let mut w = make_writer(dir.path()).await;
6481            w.set_formation_member(member.as_ref());
6482            w.write_session_start(Some(10), None, Vec::new())
6483                .await
6484                .unwrap();
6485            let door_caller = match caller {
6486                Some(caller) => crate::identity::TaskCaller::Credential(format!("member:{caller}")),
6487                None => crate::identity::TaskCaller::Operator,
6488            };
6489            w.write_a2a_task_received(
6490                "tsk_1",
6491                "ctx_1",
6492                "msg_1",
6493                None,
6494                &door_caller,
6495                caller,
6496                None,
6497                "hi",
6498            )
6499            .await
6500            .unwrap();
6501            w.flush().await.unwrap();
6502            let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
6503            let lines: Vec<Value> = content
6504                .lines()
6505                .map(|line| serde_json::from_str(line).unwrap())
6506                .collect();
6507            let start = &lines[0];
6508            let received = lines
6509                .iter()
6510                .find(|line| line["event_type"] == "a2a_task_received")
6511                .unwrap();
6512            match &member {
6513                Some(member) => {
6514                    assert_eq!(start["formation_member"], member.name());
6515                    assert_eq!(
6516                        start["formation_callees"],
6517                        serde_json::json!(member.callees().collect::<Vec<_>>())
6518                    );
6519                }
6520                None => {
6521                    assert!(start.get("formation_member").is_none(), "{start}");
6522                    assert!(start.get("formation_callees").is_none(), "{start}");
6523                }
6524            }
6525            match caller {
6526                Some(caller) => {
6527                    assert_eq!(received["caller"], "member");
6528                    assert_eq!(received["caller_member"], caller);
6529                }
6530                None => {
6531                    assert_eq!(received["caller"], "operator");
6532                    assert!(received.get("caller_member").is_none(), "{received}");
6533                }
6534            }
6535            assert!(received.get("caller_credential").is_none(), "{received}");
6536            assert!(!content.contains("mft1."), "{content}");
6537        }
6538    }
6539
6540    /// `a2a_task_received.caller` names each way a door lets a task in, with `caller_member` only
6541    /// for a member and `caller_credential` only for a declared credential; a formation token's
6542    /// task is the member's whatever credential the token carries.
6543    #[tokio::test]
6544    async fn a_received_task_names_who_the_door_let_it_in_as() {
6545        use crate::identity::TaskCaller;
6546        let cases = [
6547            (TaskCaller::Operator, None, "operator", None),
6548            (TaskCaller::Public, None, "public", None),
6549            (
6550                TaskCaller::Credential("alice".to_string()),
6551                None,
6552                "credential",
6553                Some("alice"),
6554            ),
6555            (
6556                TaskCaller::Credential("member:planner".to_string()),
6557                Some("planner"),
6558                "member",
6559                None,
6560            ),
6561        ];
6562        for (door_caller, member, caller, credential) in cases {
6563            let dir = tempfile::tempdir().unwrap();
6564            let mut w = make_writer(dir.path()).await;
6565            w.write_session_start(Some(10), None, Vec::new())
6566                .await
6567                .unwrap();
6568            w.write_a2a_task_received(
6569                "tsk_1",
6570                "ctx_1",
6571                "msg_1",
6572                None,
6573                &door_caller,
6574                member,
6575                None,
6576                "hi",
6577            )
6578            .await
6579            .unwrap();
6580            w.flush().await.unwrap();
6581            let received = read_events(dir.path())
6582                .into_iter()
6583                .find(|line| line["event_type"] == "a2a_task_received")
6584                .unwrap();
6585            assert_eq!(received["caller"], caller, "{received}");
6586            match member {
6587                Some(member) => assert_eq!(received["caller_member"], member, "{received}"),
6588                None => assert!(received.get("caller_member").is_none(), "{received}"),
6589            }
6590            match credential {
6591                Some(name) => assert_eq!(received["caller_credential"], name, "{received}"),
6592                None => assert!(received.get("caller_credential").is_none(), "{received}"),
6593            }
6594        }
6595    }
6596
6597    /// The received message's hash is written under `meta` and `content`, and only `content`
6598    /// stores the text behind it; `none` writes neither. The text never reaches the JSONL.
6599    #[tokio::test]
6600    async fn a_received_message_is_hashed_and_stored_by_capture_mode() {
6601        let text = "Name three colours, please.";
6602        let sha = murmur_artifact::sha256_hex(text.as_bytes());
6603        for capture in [
6604            TraceCapture::None,
6605            TraceCapture::Meta,
6606            TraceCapture::Content,
6607        ] {
6608            let dir = tempfile::tempdir().unwrap();
6609            let mut w = make_writer_with_opts(dir.path(), capture).await;
6610            w.write_a2a_task_received(
6611                "tsk_1",
6612                "ctx_1",
6613                "msg_1",
6614                None,
6615                &crate::identity::TaskCaller::Operator,
6616                None,
6617                None,
6618                text,
6619            )
6620            .await
6621            .unwrap();
6622            w.flush().await.unwrap();
6623            let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
6624            assert!(!content.contains(text), "{content}");
6625            let received = read_events(dir.path())
6626                .into_iter()
6627                .find(|line| line["event_type"] == "a2a_task_received")
6628                .unwrap();
6629            let blob = std::fs::read(blob_path(dir.path(), &sha)).ok();
6630            match capture {
6631                TraceCapture::None => {
6632                    assert!(received.get("message_sha256").is_none(), "{received}");
6633                    assert_eq!(blob, None);
6634                }
6635                TraceCapture::Meta => {
6636                    assert_eq!(received["message_sha256"], sha.as_str(), "{received}");
6637                    assert_eq!(blob, None);
6638                }
6639                TraceCapture::Content => {
6640                    assert_eq!(received["message_sha256"], sha.as_str(), "{received}");
6641                    assert_eq!(blob.as_deref(), Some(text.as_bytes()));
6642                }
6643            }
6644        }
6645    }
6646
6647    /// The three `system_prompt_source` values, each paired with the hash the same call derives.
6648    /// A manifest prompt and a CLI override carrying the *same* text differ only in the source
6649    /// field — which is the whole reason the override flag is threaded through separately rather
6650    /// than inferred from the resolved prompt.
6651    #[tokio::test]
6652    async fn session_start_records_system_prompt_source_and_hash() {
6653        let expected_sha = murmur_artifact::sha256_hex(b"Be terse.");
6654
6655        for (prompt, overridden, source, sha) in [
6656            (Some("Be terse."), false, "manifest", Some(&expected_sha)),
6657            (Some("Be terse."), true, "cli", Some(&expected_sha)),
6658            (None, false, "none", None),
6659            // `--system-prompt ""` cleared the manifest's prompt: no prompt is in effect, but the
6660            // operator made that call, so the source is the CLI and not `"none"`.
6661            (None, true, "cli", None),
6662        ] {
6663            let dir = tempfile::tempdir().unwrap();
6664            let mut w =
6665                make_writer_with_prompt(dir.path(), TraceCapture::Meta, prompt, overridden).await;
6666            w.write_session_start(Some(1), None, Vec::new())
6667                .await
6668                .unwrap();
6669            w.flush().await.unwrap();
6670
6671            let e = &read_events(dir.path())[0];
6672            assert_eq!(e["system_prompt_source"], source, "prompt={prompt:?}");
6673            match sha {
6674                Some(sha) => assert_eq!(e["system_prompt_sha256"], sha.as_str()),
6675                None => assert!(
6676                    e["system_prompt_sha256"].is_null(),
6677                    "no prompt in effect must hash to null, got {}",
6678                    e["system_prompt_sha256"]
6679                ),
6680            }
6681        }
6682    }
6683
6684    /// `session_start` never carries the prompt text. The hash is written in every capture
6685    /// mode; under `content` the bytes behind it are a blob named by that same hash, which is
6686    /// how a reader gets from the record to the prompt.
6687    #[tokio::test]
6688    async fn session_start_records_the_prompt_by_hash_and_stores_it_only_under_content() {
6689        let sha = murmur_artifact::sha256_hex(b"Be terse.");
6690
6691        let dir = tempfile::tempdir().unwrap();
6692        let mut w =
6693            make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), false).await;
6694        w.write_session_start(Some(1), None, Vec::new())
6695            .await
6696            .unwrap();
6697        w.flush().await.unwrap();
6698
6699        let e = &read_events(dir.path())[0];
6700        assert!(
6701            e.get("system_prompt").is_none(),
6702            "the verbatim prompt is not a session_start field, got {e}"
6703        );
6704        assert_eq!(e["system_prompt_sha256"], sha.as_str());
6705        assert!(
6706            !dir.path().join(BLOB_DIR_NAME).exists(),
6707            "meta stores no bodies"
6708        );
6709
6710        let opted_in = tempfile::tempdir().unwrap();
6711        let mut w = make_writer_with_prompt(
6712            opted_in.path(),
6713            TraceCapture::Content,
6714            Some("Be terse."),
6715            false,
6716        )
6717        .await;
6718        w.write_session_start(Some(1), None, Vec::new())
6719            .await
6720            .unwrap();
6721        w.flush().await.unwrap();
6722
6723        let event = &read_events(opted_in.path())[0];
6724        assert!(event.get("system_prompt").is_none());
6725        assert_eq!(event["system_prompt_sha256"], sha.as_str());
6726        let blob = opted_in.path().join(BLOB_DIR_NAME).join(&sha);
6727        assert_eq!(std::fs::read(&blob).unwrap(), b"Be terse.");
6728    }
6729
6730    /// Opting in cannot conjure a prompt that was never in effect — and a session with no
6731    /// prompt and nothing else to store leaves no blob directory at all.
6732    #[tokio::test]
6733    async fn content_capture_stores_no_prompt_blob_when_there_is_no_prompt() {
6734        let dir = tempfile::tempdir().unwrap();
6735        let mut w = make_writer_with_prompt(dir.path(), TraceCapture::Content, None, false).await;
6736        w.write_session_start(Some(1), None, Vec::new())
6737            .await
6738            .unwrap();
6739        w.flush().await.unwrap();
6740
6741        let e = &read_events(dir.path())[0];
6742        assert!(e.get("system_prompt").is_none());
6743        assert!(e["system_prompt_sha256"].is_null());
6744        assert!(!dir.path().join(BLOB_DIR_NAME).exists());
6745    }
6746
6747    /// The prompt is a session constant, like `model` — every task's `session_start` repeats it.
6748    #[tokio::test]
6749    async fn every_task_session_start_repeats_the_same_system_prompt_record() {
6750        let dir = tempfile::tempdir().unwrap();
6751        let mut w =
6752            make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), true).await;
6753        w.write_session_start(Some(1), None, Vec::new())
6754            .await
6755            .unwrap();
6756        w.write_session_start(Some(1), None, Vec::new())
6757            .await
6758            .unwrap();
6759        w.flush().await.unwrap();
6760
6761        let events = read_events(dir.path());
6762        assert_eq!(events.len(), 2);
6763        assert_eq!(
6764            events[0]["system_prompt_source"],
6765            events[1]["system_prompt_source"]
6766        );
6767        assert_eq!(
6768            events[0]["system_prompt_sha256"],
6769            events[1]["system_prompt_sha256"]
6770        );
6771        assert_eq!(events[1]["system_prompt_source"], "cli");
6772    }
6773
6774    /// The pairing an operator reads a trace for: `workdir_exec: true` next to the `advisory` it
6775    /// forces, on a session whose *declared* floor was higher. Without the flag on the record, a
6776    /// trace showing `achieved: advisory` is indistinguishable from one written on a host with no
6777    /// Landlock at all.
6778    #[tokio::test]
6779    async fn session_start_records_workdir_exec_next_to_the_class_it_forced() {
6780        let dir = tempfile::tempdir().unwrap();
6781        let mut w = TraceWriter::open(
6782            &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6783            "test-session-id".to_string(),
6784            "test-capsule".to_string(),
6785            "1.0.0".to_string(),
6786            "claude-test".to_string(),
6787            Vec::new(),
6788            // A host that could back `scoped`, capped to `advisory` by the declaration alone.
6789            report_for(
6790                ContainmentClass::Advisory,
6791                EnforcementTier::KernelFull,
6792                true,
6793            ),
6794            TraceCapture::Meta,
6795            None,
6796            false,
6797            None,
6798            None,
6799            None,
6800            None,
6801        )
6802        .await
6803        .unwrap();
6804        w.write_session_start(Some(1), None, Vec::new())
6805            .await
6806            .unwrap();
6807        w.flush().await.unwrap();
6808
6809        let events = read_events(dir.path());
6810        assert_eq!(events[0]["workdir_exec"], true);
6811        assert_eq!(events[0]["containment_achieved"], "advisory");
6812    }
6813
6814    /// A declared floor and an achieved class are recorded independently — the trace shows
6815    /// what was asked for next to what the host actually gave, not one derived from the other.
6816    #[tokio::test]
6817    async fn session_start_records_declared_and_achieved_containment_separately() {
6818        let dir = tempfile::tempdir().unwrap();
6819        let mut w = TraceWriter::open(
6820            &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6821            "test-session-id".to_string(),
6822            "test-capsule".to_string(),
6823            "1.0.0".to_string(),
6824            "claude-test".to_string(),
6825            Vec::new(),
6826            report_for(ContainmentClass::Sealed, EnforcementTier::KernelFull, false),
6827            TraceCapture::Meta,
6828            None,
6829            false,
6830            None,
6831            None,
6832            None,
6833            None,
6834        )
6835        .await
6836        .unwrap();
6837        w.write_session_start(Some(1), None, Vec::new())
6838            .await
6839            .unwrap();
6840        w.flush().await.unwrap();
6841
6842        let events = read_events(dir.path());
6843        assert_eq!(events[0]["containment_declared"], "sealed");
6844        assert_eq!(events[0]["containment_achieved"], "scoped");
6845    }
6846
6847    /// The audit property the containment feature exists to provide: two sessions that reached
6848    /// the *same* achieved class through *different* host permissions must not produce the same
6849    /// record. A `sealed` result obtained through the shipped AppArmor profile and one obtained on
6850    /// a host whose unprivileged-userns hardening is switched off for every binary differ in
6851    /// nothing else the event carries, so `userns_grant` is what keeps the two apart.
6852    ///
6853    /// The tier is a literal on both sides, so the only thing that differs is the grant.
6854    #[tokio::test]
6855    async fn session_start_distinguishes_two_hosts_with_the_same_achieved_class() {
6856        async fn event_for(grant: UsernsGrant) -> Value {
6857            let report = crate::containment::report_for(
6858                &CapabilityPolicy::default(),
6859                ContainmentClass::Sealed,
6860                EnforcementTier::KernelSealed,
6861                None,
6862                Some(grant),
6863                None,
6864                None,
6865                Vec::new(),
6866                Vec::new(),
6867                Vec::new(),
6868                &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6869                crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6870                    EnforcementTier::KernelSealed,
6871                    ContainmentClass::Sealed,
6872                )),
6873                crate::cgroup::IoMaxReport::default(),
6874            );
6875            let dir = tempfile::tempdir().unwrap();
6876            let mut w = TraceWriter::open(
6877                &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6878                "test-session-id".to_string(),
6879                "test-capsule".to_string(),
6880                "1.0.0".to_string(),
6881                "claude-test".to_string(),
6882                Vec::new(),
6883                report,
6884                TraceCapture::Meta,
6885                None,
6886                false,
6887                None,
6888                None,
6889                None,
6890                None,
6891            )
6892            .await
6893            .unwrap();
6894            w.write_session_start(Some(1), None, Vec::new())
6895                .await
6896                .unwrap();
6897            w.flush().await.unwrap();
6898            let mut event = read_events(dir.path()).remove(0);
6899            // The one field that legitimately differs between two runs, removed so the assertion
6900            // below is about the grant and nothing else.
6901            event.as_object_mut().unwrap().remove("timestamp");
6902            event
6903        }
6904
6905        let through_profile = event_for(UsernsGrant::ProfileConfining).await;
6906        let host_wide = event_for(UsernsGrant::RestrictionDisabledHostWide).await;
6907
6908        assert_eq!(through_profile["containment_achieved"], "sealed");
6909        assert_eq!(host_wide["containment_achieved"], "sealed");
6910        assert_eq!(through_profile["userns_grant"], "profile_confining");
6911        assert_eq!(host_wide["userns_grant"], "restriction_disabled_host_wide");
6912        assert_ne!(
6913            through_profile, host_wide,
6914            "two hosts granting the user namespace by different mechanisms must not write the \
6915             same session_start record"
6916        );
6917
6918        // Written for every Linux host, never skipped — its absence identifies a runtime that
6919        // predates the key rather than a host that was not asked.
6920        let unprobed = crate::containment::report_for(
6921            &CapabilityPolicy::default(),
6922            ContainmentClass::Advisory,
6923            EnforcementTier::EnvironmentOnly,
6924            None,
6925            None,
6926            None,
6927            None,
6928            Vec::new(),
6929            Vec::new(),
6930            Vec::new(),
6931            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6932            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6933                EnforcementTier::EnvironmentOnly,
6934                ContainmentClass::Advisory,
6935            )),
6936            crate::cgroup::IoMaxReport::default(),
6937        );
6938        assert!(
6939            serde_json::to_value(&unprobed).unwrap()["userns_grant"].is_null(),
6940            "off Linux the key is present and null, not absent"
6941        );
6942    }
6943
6944    /// Central property: `session_start.effective_grants` is the *whole* `ScopeReport`,
6945    /// serialized byte-for-byte as `mur run --explain-scope --json` prints it — not a re-derived
6946    /// summary of it. Asserted by comparing against `serde_json::to_value` of the very report
6947    /// handed to the writer, so any field added to `ScopeReport` later is covered without this
6948    /// test being touched.
6949    ///
6950    /// The tier is a literal, never a probe: this asserts the recording, not the host.
6951    #[tokio::test]
6952    async fn session_start_records_the_whole_scope_report_as_effective_grants() {
6953        let policy = CapabilityPolicy {
6954            network_allow: vec!["https://api.example.com".to_string()],
6955            unix_sockets_allowed: false,
6956            filesystem_scope: Some("workdir".to_string()),
6957            shell_allow: vec!["python3".to_string()],
6958            spawn_allow: vec!["helper".to_string()],
6959            env_allow: vec!["TZ".to_string()],
6960            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
6961                binary: "python3".to_string(),
6962                dirs: vec![InterpreterRuntimeDir {
6963                    path: "/usr/lib/python3.11".to_string(),
6964                    list_dir: true,
6965                }],
6966            }],
6967            ..CapabilityPolicy::default()
6968        };
6969        let expected = crate::containment::report_for(
6970            &policy,
6971            ContainmentClass::Scoped,
6972            EnforcementTier::KernelFull,
6973            None,
6974            None,
6975            // A kernel above the cap, so `landlock_abi` is written as an integer, not `null`.
6976            Some(8),
6977            None,
6978            // Populated rather than empty so the whole-report assertion below really covers this
6979            // field: an empty vec would pass whether or not it was written at all.
6980            vec![crate::containment::StateStoreReport {
6981                artifact: "notes-tool".to_string(),
6982                store: "shey".to_string(),
6983                host_path: "/home/dev/.murmur/state/shey".to_string(),
6984            }],
6985            // Populated for the same reason as `state_stores` above.
6986            vec!["config-echo".to_string()],
6987            vec![crate::containment::PreopenReport {
6988                artifact: "notes-tool".to_string(),
6989                role: "tool".to_string(),
6990                scope: None,
6991                surface: crate::containment::PreopenSurface::WholeWorkdir,
6992                runtime_dir_reach: Some(crate::containment::RuntimeDirReach::MurmurRuntime),
6993                // Populated for the same reason as `state_stores` above.
6994                extra_roots: vec![crate::containment::ExtraRootReport {
6995                    guest_path: "murmur-host/home/dev/.cargo".to_string(),
6996                    path: "/home/dev/.cargo".to_string(),
6997                    access: crate::host_paths::HostPathAccess::Read,
6998                    source: crate::guest_roots::GuestRootSource::HostPaths,
6999                }],
7000            }],
7001            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
7002            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
7003                EnforcementTier::KernelFull,
7004                ContainmentClass::Scoped,
7005            )),
7006            crate::cgroup::IoMaxReport::default(),
7007        );
7008
7009        let dir = tempfile::tempdir().unwrap();
7010        let mut w = TraceWriter::open(
7011            &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
7012            "test-session-id".to_string(),
7013            "test-capsule".to_string(),
7014            "1.0.0".to_string(),
7015            "claude-test".to_string(),
7016            vec!["network".to_string(), "shell".to_string()],
7017            expected.clone(),
7018            TraceCapture::Meta,
7019            None,
7020            false,
7021            None,
7022            None,
7023            None,
7024            None,
7025        )
7026        .await
7027        .unwrap();
7028        w.write_session_start(Some(1), None, Vec::new())
7029            .await
7030            .unwrap();
7031        w.flush().await.unwrap();
7032
7033        let events = read_events(dir.path());
7034        assert_eq!(
7035            events[0]["effective_grants"],
7036            serde_json::to_value(&expected).unwrap()
7037        );
7038
7039        // The category-name summary that predates this field is unchanged, and the three
7040        // top-level containment fields agree with the report they are now read from.
7041        assert_eq!(
7042            events[0]["capabilities"],
7043            serde_json::json!(["network", "shell"])
7044        );
7045        assert_eq!(
7046            events[0]["effective_grants"]["state_stores"],
7047            serde_json::json!([{
7048                "artifact": "notes-tool",
7049                "store": "shey",
7050                "host_path": "/home/dev/.murmur/state/shey",
7051            }])
7052        );
7053        // Names only: what an artifact is configured *with* is operator plaintext and reaches
7054        // no trace record.
7055        assert_eq!(
7056            events[0]["effective_grants"]["configured_artifacts"],
7057            serde_json::json!(["config-echo"])
7058        );
7059        // The per-artifact preopen set reaches the trace with no trace-side plumbing: the report
7060        // is cloned whole, so `filesystem_scope` above (which narrows no guest) and the surface a
7061        // guest is actually opened into are both on the record.
7062        assert_eq!(
7063            events[0]["effective_grants"]["preopens"],
7064            serde_json::json!([{
7065                "artifact": "notes-tool",
7066                "role": "tool",
7067                "scope": null,
7068                "surface": "whole-workdir",
7069                "runtime_dir_reach": "murmur-runtime",
7070                "extra_roots": [{
7071                    "guest_path": "murmur-host/home/dev/.cargo",
7072                    "path": "/home/dev/.cargo",
7073                    "access": "read",
7074                    "source": "host_paths",
7075                }],
7076            }])
7077        );
7078        assert_eq!(events[0]["containment_declared"], "scoped");
7079        assert_eq!(events[0]["containment_achieved"], "scoped");
7080        assert_eq!(events[0]["workdir_exec"], false);
7081        assert_eq!(
7082            events[0]["effective_grants"]["network_allow"],
7083            serde_json::json!(["https://api.example.com"])
7084        );
7085        assert_eq!(
7086            events[0]["effective_grants"]["interpreter_runtime_grants"],
7087            serde_json::json!(["python3: /usr/lib/python3.11 (list_dir)"])
7088        );
7089    }
7090
7091    #[tokio::test]
7092    async fn inference_fields_and_snake_case() {
7093        let dir = tempfile::tempdir().unwrap();
7094        let mut w = make_writer(dir.path()).await;
7095        w.write_inference(
7096            0,
7097            Some(100),
7098            Some(50),
7099            "tool_call".to_string(),
7100            Some("tool_call"),
7101            Some("bash".to_string()),
7102            None,
7103            None,
7104            vec!["msg_0000000000000000000000000000000a".to_string()],
7105            None,
7106            None,
7107        )
7108        .await
7109        .unwrap();
7110        w.flush().await.unwrap();
7111
7112        let events = read_events(dir.path());
7113        let e = &events[0];
7114        assert_eq!(e["event_type"], "inference");
7115        assert_eq!(
7116            e["message_ids"],
7117            serde_json::json!(["msg_0000000000000000000000000000000a"]),
7118            "an agent-loop turn names the messages its request embedded"
7119        );
7120        assert_eq!(e["turn"], 0);
7121        assert_eq!(e["input_tokens"], 100);
7122        assert_eq!(e["output_tokens"], 50);
7123        assert_eq!(e["decision"], "tool_call");
7124        assert_eq!(e["tool_name"], "bash");
7125        // Confirm snake_case (not camelCase)
7126        assert!(e.get("inputTokens").is_none(), "must use snake_case");
7127        assert!(e.get("toolName").is_none(), "must use snake_case");
7128        // No driver reported usage, so no key claims one was reported.
7129        for key in [
7130            "input_tokens_actual",
7131            "output_tokens_actual",
7132            "cached_tokens",
7133            "cache_write_tokens",
7134        ] {
7135            assert!(e.get(key).is_none(), "{key} must be omitted, not null or 0");
7136        }
7137    }
7138
7139    /// A partially-populated `usage` writes only the members the driver reported: a member it
7140    /// left out stays off the line entirely rather than being recorded as a zero.
7141    #[tokio::test]
7142    async fn inference_writes_only_the_reported_usage_members() {
7143        let dir = tempfile::tempdir().unwrap();
7144        let mut w = make_writer(dir.path()).await;
7145        let usage = DriverUsage {
7146            input_tokens: Some(12043),
7147            output_tokens: None,
7148            cached_tokens: Some(0),
7149            cache_write_tokens: None,
7150            thinking_tokens: None,
7151        };
7152        w.write_inference(
7153            0,
7154            Some(100),
7155            Some(50),
7156            "end_turn".to_string(),
7157            Some("end_turn"),
7158            None,
7159            None,
7160            Some(&usage),
7161            Vec::new(),
7162            None,
7163            None,
7164        )
7165        .await
7166        .unwrap();
7167        w.flush().await.unwrap();
7168
7169        let events = read_events(dir.path());
7170        let e = &events[0];
7171        assert_eq!(e["input_tokens"], 100, "the estimate is untouched");
7172        assert_eq!(e["input_tokens_actual"], 12043);
7173        assert_eq!(e["cached_tokens"], 0, "a reported zero is written");
7174        assert!(e.get("output_tokens_actual").is_none());
7175        assert!(e.get("cache_write_tokens").is_none());
7176    }
7177
7178    #[tokio::test]
7179    async fn failed_inference_writes_its_keys_and_counts_only_what_it_spent() {
7180        let dir = tempfile::tempdir().unwrap();
7181        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7182        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
7183            .await
7184            .unwrap();
7185        let usage = DriverUsage {
7186            input_tokens: Some(7),
7187            ..DriverUsage::default()
7188        };
7189        let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
7190        w.write_failed_inference(FailedInference {
7191            turn: 0,
7192            error_code: INFERENCE_ERROR_CREDENTIAL_REJECTED,
7193            error: &long,
7194            provider_status: Some(401),
7195            usage: Some(&usage),
7196            origin: None,
7197            choice: None,
7198            message_ids: vec!["msg_1".to_string()],
7199            tool_name: None,
7200            spent: None,
7201            settings: None,
7202            retry: RetryNote::default(),
7203            gateway: None,
7204        })
7205        .await
7206        .unwrap();
7207        assert_eq!(w.task_turns(), 0, "an uncounted record moves no counter");
7208        assert_eq!((w.total_turns, w.total_input_tokens), (0, 0));
7209        assert_eq!((w.task_input_tokens, w.task_output_tokens), (0, 0));
7210
7211        w.write_failed_inference(FailedInference {
7212            turn: 1,
7213            error_code: INFERENCE_ERROR_HARNESS_AUTH,
7214            error: "bad login",
7215            provider_status: None,
7216            usage: None,
7217            origin: None,
7218            choice: None,
7219            message_ids: Vec::new(),
7220            tool_name: None,
7221            spent: Some((Some(10), Some(5))),
7222            settings: None,
7223            retry: RetryNote::default(),
7224            gateway: None,
7225        })
7226        .await
7227        .unwrap();
7228        assert_eq!(w.task_turns(), 1);
7229        assert_eq!(w.total_turns, 1);
7230        assert_eq!((w.total_input_tokens, w.total_output_tokens), (10, 5));
7231        assert_eq!((w.task_input_tokens, w.task_output_tokens), (10, 5));
7232        w.flush().await.unwrap();
7233
7234        let events = read_events(dir.path());
7235        let failed: Vec<&Value> = events
7236            .iter()
7237            .filter(|e| e["event_type"] == "inference")
7238            .collect();
7239        let keys = |e: &Value| {
7240            let mut keys: Vec<String> = e.as_object().unwrap().keys().cloned().collect();
7241            keys.sort();
7242            keys
7243        };
7244        assert_eq!(
7245            keys(failed[0]),
7246            [
7247                "decision",
7248                "error",
7249                "error_code",
7250                "event_id",
7251                "event_type",
7252                "input_tokens_actual",
7253                "message_ids",
7254                "parent_id",
7255                "prev_hash",
7256                "provider_status",
7257                "session_id",
7258                "stop_reason",
7259                "task_id",
7260                "timestamp",
7261                "tool_name",
7262                "turn",
7263            ]
7264        );
7265        assert_eq!(failed[0]["decision"], "error");
7266        assert_eq!(failed[0]["stop_reason"], "error");
7267        assert_eq!(failed[0]["error_code"], "credential_rejected");
7268        assert_eq!(failed[0]["provider_status"], 401);
7269        assert_eq!(failed[0]["input_tokens_actual"], 7);
7270        let error = failed[0]["error"].as_str().unwrap();
7271        assert!(error.len() <= MAX_TASK_FAILURE_REASON_BYTES);
7272        assert!(error.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
7273
7274        assert_eq!(failed[1]["error_code"], "harness_auth");
7275        assert_eq!(failed[1]["error"], "bad login");
7276        assert_eq!(failed[1]["input_tokens"], 10);
7277        assert_eq!(failed[1]["output_tokens"], 5);
7278        assert!(failed[1].get("provider_status").is_none());
7279        assert!(
7280            !dir.path().join("blobs").exists(),
7281            "a failed record stores no blob, even under trace.capture: content"
7282        );
7283        // The agent-loop failed record is the turn node, parented to the task.
7284        let task_start = events
7285            .iter()
7286            .find(|e| e["event_type"] == "task_start")
7287            .unwrap();
7288        assert_eq!(failed[0]["parent_id"], task_start["event_id"]);
7289    }
7290
7291    /// The gateway timing keys: absent with no runtime gateway, `null` for a moment that never
7292    /// came, numbers for one that did.
7293    #[tokio::test]
7294    async fn gateway_timing_keys_follow_the_gateway() {
7295        let dir = tempfile::tempdir().unwrap();
7296        let mut w = make_writer(dir.path()).await;
7297        let failed = |turn, gateway| FailedInference {
7298            turn,
7299            error_code: INFERENCE_ERROR_DRIVER_FAILED,
7300            error: "stopped",
7301            provider_status: None,
7302            usage: None,
7303            origin: None,
7304            choice: None,
7305            message_ids: Vec::new(),
7306            tool_name: None,
7307            spent: None,
7308            settings: None,
7309            retry: RetryNote::default(),
7310            gateway,
7311        };
7312        w.write_failed_inference(failed(0, None)).await.unwrap();
7313        w.write_failed_inference(failed(1, Some(GatewayOffsets::default())))
7314            .await
7315            .unwrap();
7316        let answered = GatewayOffsets {
7317            reached_ms: Some(4),
7318            responded_ms: Some(90),
7319        };
7320        w.write_failed_inference(failed(2, Some(answered)))
7321            .await
7322            .unwrap();
7323        w.flush().await.unwrap();
7324
7325        let events = read_events(dir.path());
7326        assert!(events[0].get("gateway_reached_ms").is_none());
7327        assert!(events[0].get("upstream_responded_ms").is_none());
7328        assert_eq!(events[1]["gateway_reached_ms"], Value::Null);
7329        assert_eq!(events[1]["upstream_responded_ms"], Value::Null);
7330        assert!(events[1]
7331            .as_object()
7332            .unwrap()
7333            .contains_key("gateway_reached_ms"));
7334        assert!(events[1]
7335            .as_object()
7336            .unwrap()
7337            .contains_key("upstream_responded_ms"));
7338        assert_eq!(events[2]["gateway_reached_ms"], 4);
7339        assert_eq!(events[2]["upstream_responded_ms"], 90);
7340    }
7341
7342    /// A firing in the latch is written as one `call_deadline` line ahead of the next line the
7343    /// writer writes, inside the task and under the previous turn, and is not written twice. One
7344    /// from before the session frame follows `session_start`.
7345    #[tokio::test]
7346    async fn call_deadlines_are_drained_ahead_of_the_next_line() {
7347        let dir = tempfile::tempdir().unwrap();
7348        let mut w = make_writer(dir.path()).await;
7349        let deadlines = crate::limits::CallDeadlines::default();
7350        w.set_call_deadlines(deadlines.clone());
7351        let staged = |component: &str, call: &str| crate::limits::CallDeadline {
7352            component: component.to_string(),
7353            role: crate::limits::ComponentRole::Hook,
7354            call: call.to_string(),
7355            deadline_seconds: 2,
7356            elapsed_ms: 4_000,
7357        };
7358        deadlines.push_for_test(staged("stager", "on-stage"));
7359        w.write_session_start(None, None, Vec::new()).await.unwrap();
7360        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
7361            .await
7362            .unwrap();
7363        deadlines.push_for_test(crate::limits::CallDeadline {
7364            component: "stall-driver".to_string(),
7365            role: crate::limits::ComponentRole::Driver,
7366            call: "run".to_string(),
7367            deadline_seconds: 3,
7368            elapsed_ms: 5_001,
7369        });
7370        w.write_failed_inference(FailedInference {
7371            turn: 0,
7372            error_code: INFERENCE_ERROR_DRIVER_FAILED,
7373            error: "stopped",
7374            provider_status: None,
7375            usage: None,
7376            origin: None,
7377            choice: None,
7378            message_ids: Vec::new(),
7379            tool_name: None,
7380            spent: None,
7381            settings: None,
7382            retry: RetryNote::default(),
7383            gateway: Some(GatewayOffsets::default()),
7384        })
7385        .await
7386        .unwrap();
7387        w.write_session_end("ok").await.unwrap();
7388        w.flush().await.unwrap();
7389
7390        let events = read_events(dir.path());
7391        let types: Vec<&str> = events
7392            .iter()
7393            .map(|e| e["event_type"].as_str().unwrap())
7394            .collect();
7395        assert_eq!(
7396            types,
7397            [
7398                "session_start",
7399                "call_deadline",
7400                "task_start",
7401                "call_deadline",
7402                "inference",
7403                "session_end"
7404            ]
7405        );
7406        assert_eq!(events[1]["call"], "on-stage");
7407        assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
7408        assert!(events[1].get("task_id").is_none(), "outside a task");
7409        let cut = &events[3];
7410        assert_eq!(cut["component"], "stall-driver");
7411        assert_eq!(cut["role"], "driver");
7412        assert_eq!(cut["call"], "run");
7413        assert_eq!(cut["deadline_seconds"], 3);
7414        assert_eq!(cut["elapsed_ms"], 5_001);
7415        assert_eq!(cut["task_id"], "tsk_1");
7416        assert_eq!(cut["parent_id"], events[2]["event_id"]);
7417        assert!(cut["event_id"].as_str().is_some());
7418        assert!(cut["timestamp"].as_u64().is_some());
7419    }
7420
7421    #[tokio::test]
7422    async fn a_failed_hook_record_hangs_off_the_turn_and_keeps_hook_accounting() {
7423        let dir = tempfile::tempdir().unwrap();
7424        let mut w = make_writer(dir.path()).await;
7425        w.write_inference(
7426            0,
7427            Some(10),
7428            Some(5),
7429            "end_turn".to_string(),
7430            Some("end_turn"),
7431            None,
7432            None,
7433            None,
7434            Vec::new(),
7435            None,
7436            None,
7437        )
7438        .await
7439        .unwrap();
7440        let origin = InferenceOrigin {
7441            source: "hook:compactor".to_string(),
7442            model: "m".to_string(),
7443        };
7444        w.write_failed_inference(FailedInference {
7445            turn: 0,
7446            error_code: INFERENCE_ERROR_DRIVER_ERROR,
7447            error: "HTTP 500: boom",
7448            provider_status: Some(500),
7449            usage: None,
7450            origin: Some(&origin),
7451            choice: None,
7452            message_ids: Vec::new(),
7453            tool_name: None,
7454            spent: Some((Some(40), None)),
7455            settings: None,
7456            retry: RetryNote::default(),
7457            gateway: None,
7458        })
7459        .await
7460        .unwrap();
7461        assert_eq!(w.total_turns, 2);
7462        assert_eq!((w.total_input_tokens, w.total_output_tokens), (50, 5));
7463        w.flush().await.unwrap();
7464
7465        let events = read_events(dir.path());
7466        let turn = events.iter().find(|e| e["decision"] == "end_turn").unwrap();
7467        let hook = events.iter().find(|e| e["decision"] == "error").unwrap();
7468        assert_eq!(hook["parent_id"], turn["event_id"]);
7469        assert_eq!(hook["origin"], "hook:compactor");
7470        assert_eq!(hook["model"], "m");
7471        assert_eq!(hook["input_tokens"], 40);
7472        assert!(hook.get("output_tokens").is_none());
7473    }
7474
7475    /// Every `http` line names how its call was sent: the cap, the input budget and the driver
7476    /// config, every key present, `input_budget` written as `null` with no window and
7477    /// `driver_config` as `null` when the driver saw none.
7478    #[tokio::test]
7479    async fn inference_call_settings_are_written_on_every_kind_of_http_line() {
7480        let dir = tempfile::tempdir().unwrap();
7481        let mut w = make_writer(dir.path()).await;
7482        let turn = InferenceCallSettings {
7483            max_output_tokens: 4096,
7484            input_budget: Some(195_904),
7485            driver_config: Some(serde_json::json!({"reasoning_effort": "high"})),
7486        };
7487        let unconfigured = InferenceCallSettings {
7488            max_output_tokens: 1024,
7489            input_budget: None,
7490            driver_config: None,
7491        };
7492        let hook = InferenceCallSettings {
7493            max_output_tokens: 2000,
7494            input_budget: Some(198_000),
7495            driver_config: Some(serde_json::json!({"reasoning_effort": "low"})),
7496        };
7497        let origin = InferenceOrigin {
7498            source: "hook:compactor".to_string(),
7499            model: "m".to_string(),
7500        };
7501        w.write_turn_inference(
7502            0,
7503            Some(10),
7504            Some(5),
7505            "end_turn".to_string(),
7506            Some("end_turn"),
7507            None,
7508            None,
7509            Vec::new(),
7510            None,
7511            None,
7512            Some(&turn),
7513            RetryNote::default(),
7514            None,
7515        )
7516        .await
7517        .unwrap();
7518        w.write_turn_inference(
7519            1,
7520            Some(10),
7521            Some(5),
7522            "end_turn".to_string(),
7523            Some("end_turn"),
7524            None,
7525            None,
7526            Vec::new(),
7527            None,
7528            None,
7529            Some(&unconfigured),
7530            RetryNote::default(),
7531            None,
7532        )
7533        .await
7534        .unwrap();
7535        w.write_inference(
7536            1,
7537            Some(30),
7538            Some(8),
7539            "end_turn".to_string(),
7540            None,
7541            None,
7542            Some(&origin),
7543            None,
7544            Vec::new(),
7545            None,
7546            Some(&hook),
7547        )
7548        .await
7549        .unwrap();
7550        w.write_failed_inference(FailedInference {
7551            turn: 2,
7552            error_code: INFERENCE_ERROR_DRIVER_ERROR,
7553            error: "HTTP 500: boom",
7554            provider_status: Some(500),
7555            usage: None,
7556            origin: None,
7557            choice: None,
7558            message_ids: Vec::new(),
7559            tool_name: None,
7560            spent: None,
7561            settings: Some(&turn),
7562            retry: RetryNote::default(),
7563            gateway: None,
7564        })
7565        .await
7566        .unwrap();
7567        w.flush().await.unwrap();
7568
7569        let events = read_events(dir.path());
7570        let settings: Vec<(Value, Value, Value, Value)> = events
7571            .iter()
7572            .filter(|e| e["event_type"] == "inference")
7573            .map(|e| {
7574                (
7575                    e["decision"].clone(),
7576                    e["max_output_tokens"].clone(),
7577                    e.get("input_budget")
7578                        .cloned()
7579                        .expect("input_budget is written, null or not"),
7580                    e.get("driver_config")
7581                        .cloned()
7582                        .expect("driver_config is written, null or not"),
7583                )
7584            })
7585            .collect();
7586        assert_eq!(
7587            settings,
7588            [
7589                (
7590                    serde_json::json!("end_turn"),
7591                    serde_json::json!(4096),
7592                    serde_json::json!(195_904),
7593                    serde_json::json!({"reasoning_effort": "high"})
7594                ),
7595                (
7596                    serde_json::json!("end_turn"),
7597                    serde_json::json!(1024),
7598                    Value::Null,
7599                    Value::Null
7600                ),
7601                (
7602                    serde_json::json!("end_turn"),
7603                    serde_json::json!(2000),
7604                    serde_json::json!(198_000),
7605                    serde_json::json!({"reasoning_effort": "low"})
7606                ),
7607                (
7608                    serde_json::json!("error"),
7609                    serde_json::json!(4096),
7610                    serde_json::json!(195_904),
7611                    serde_json::json!({"reasoning_effort": "high"})
7612                ),
7613            ]
7614        );
7615    }
7616
7617    /// A line written without settings — every `process` line — carries none of the keys.
7618    #[tokio::test]
7619    async fn an_inference_line_without_call_settings_writes_neither_key() {
7620        let dir = tempfile::tempdir().unwrap();
7621        let mut w = make_writer(dir.path()).await;
7622        w.write_inference(
7623            0,
7624            Some(10),
7625            Some(5),
7626            "end_turn".to_string(),
7627            None,
7628            None,
7629            None,
7630            None,
7631            Vec::new(),
7632            None,
7633            None,
7634        )
7635        .await
7636        .unwrap();
7637        w.write_failed_inference(FailedInference {
7638            turn: 1,
7639            error_code: INFERENCE_ERROR_HARNESS_AUTH,
7640            error: "bad login",
7641            provider_status: None,
7642            usage: None,
7643            origin: None,
7644            choice: None,
7645            message_ids: Vec::new(),
7646            tool_name: None,
7647            spent: None,
7648            settings: None,
7649            retry: RetryNote::default(),
7650            gateway: None,
7651        })
7652        .await
7653        .unwrap();
7654        w.flush().await.unwrap();
7655
7656        let events = read_events(dir.path());
7657        let lines: Vec<&Value> = events
7658            .iter()
7659            .filter(|e| e["event_type"] == "inference")
7660            .collect();
7661        assert_eq!(lines.len(), 2);
7662        for line in lines {
7663            assert!(line.get("max_output_tokens").is_none(), "{line}");
7664            assert!(line.get("input_budget").is_none(), "{line}");
7665            assert!(line.get("driver_config").is_none(), "{line}");
7666        }
7667    }
7668
7669    fn driver_config_env(value: &str) -> Vec<(String, String)> {
7670        vec![
7671            ("MURMUR_INFERENCE_MODEL".to_string(), "gpt-5".to_string()),
7672            (
7673                "MURMUR_INFERENCE_DRIVER_CONFIG".to_string(),
7674                value.to_string(),
7675            ),
7676        ]
7677    }
7678
7679    fn redact_secret(text: &str) -> String {
7680        text.replace("sk-secret", crate::gateway_credential::REDACTED)
7681    }
7682
7683    #[test]
7684    fn recorded_driver_config_is_none_without_the_variable() {
7685        let env = vec![("MURMUR_INFERENCE_MODEL".to_string(), "gpt-5".to_string())];
7686        assert_eq!(recorded_driver_config(&env, redact_secret), None);
7687    }
7688
7689    #[test]
7690    fn recorded_driver_config_is_the_parsed_object() {
7691        assert_eq!(
7692            recorded_driver_config(
7693                &driver_config_env(r#"{"reasoning_effort":"low"}"#),
7694                redact_secret
7695            ),
7696            Some(serde_json::json!({"reasoning_effort": "low"}))
7697        );
7698    }
7699
7700    #[test]
7701    fn recorded_driver_config_redacts_keys_and_string_leaves_at_any_depth() {
7702        let env = driver_config_env(
7703            r#"{"note":"x-sk-secret-y","nested":{"list":["sk-secret"]},"sk-secret":1}"#,
7704        );
7705        assert_eq!(
7706            recorded_driver_config(&env, redact_secret),
7707            Some(serde_json::json!({
7708                "note": "x-[redacted]-y",
7709                "nested": {"list": ["[redacted]"]},
7710                "[redacted]": 1,
7711            }))
7712        );
7713    }
7714
7715    #[test]
7716    fn recorded_driver_config_replaces_a_number_the_redactor_changes() {
7717        let redact_pin = |text: &str| text.replace("424242", crate::gateway_credential::REDACTED);
7718        let env = driver_config_env(r#"{"pin":424242,"retries":3,"store":false,"seed":null}"#);
7719        assert_eq!(
7720            recorded_driver_config(&env, redact_pin),
7721            Some(serde_json::json!({
7722                "pin": "[redacted]",
7723                "retries": 3,
7724                "store": false,
7725                "seed": null,
7726            }))
7727        );
7728    }
7729
7730    #[test]
7731    fn recorded_driver_config_records_text_that_is_not_json_as_a_redacted_string() {
7732        assert_eq!(
7733            recorded_driver_config(&driver_config_env("effort=sk-secret"), redact_secret),
7734            Some(Value::String("effort=[redacted]".to_string()))
7735        );
7736    }
7737
7738    #[tokio::test]
7739    async fn tool_call_fields() {
7740        let dir = tempfile::tempdir().unwrap();
7741        let mut w = make_writer(dir.path()).await;
7742        w.write_tool_call(
7743            1,
7744            "bash".to_string(),
7745            None,
7746            serde_json::json!({"command": "echo hi"}),
7747            42,
7748            "hi\n",
7749            100,
7750            55,
7751            "ok".to_string(),
7752            None,
7753            None,
7754            None,
7755        )
7756        .await
7757        .unwrap();
7758        w.flush().await.unwrap();
7759
7760        let events = read_events(dir.path());
7761        let e = &events[0];
7762        assert_eq!(e["event_type"], "tool_call");
7763        assert_eq!(e["turn"], 1);
7764        assert_eq!(e["tool_name"], "bash");
7765        assert_eq!(e["input_bytes"], 42);
7766        assert_eq!(e["output_bytes"], 100);
7767        assert_eq!(e["duration_ms"], 55);
7768        assert_eq!(e["status"], "ok");
7769        assert!(
7770            e.get("state_effect").is_none(),
7771            "state_effect must be omitted when the tool declared nothing"
7772        );
7773    }
7774
7775    #[tokio::test]
7776    async fn tool_call_records_declared_state_effect() {
7777        let dir = tempfile::tempdir().unwrap();
7778        let mut w = make_writer(dir.path()).await;
7779        w.write_tool_call(
7780            0,
7781            "murmur-tool-editor".to_string(),
7782            None,
7783            serde_json::json!({"operation": "read_file", "path": "a.txt"}),
7784            10,
7785            "hi\n",
7786            3,
7787            5,
7788            "ok".to_string(),
7789            Some("read".to_string()),
7790            None,
7791            None,
7792        )
7793        .await
7794        .unwrap();
7795        w.flush().await.unwrap();
7796
7797        let events = read_events(dir.path());
7798        assert_eq!(
7799            events[0]["state_effect"], "read",
7800            "declared state_effect must be recorded verbatim on the tool_call event"
7801        );
7802    }
7803
7804    #[tokio::test]
7805    async fn tool_call_records_declared_resource_id() {
7806        let dir = tempfile::tempdir().unwrap();
7807        let mut w = make_writer(dir.path()).await;
7808        w.write_tool_call(
7809            0,
7810            "murmur-tool-code-graph".to_string(),
7811            None,
7812            serde_json::json!({"symbol": "Foo::bar"}),
7813            10,
7814            "hi\n",
7815            3,
7816            5,
7817            "ok".to_string(),
7818            Some("read".to_string()),
7819            Some("sym:Foo".to_string()),
7820            None,
7821        )
7822        .await
7823        .unwrap();
7824        w.flush().await.unwrap();
7825
7826        let events = read_events(dir.path());
7827        assert_eq!(
7828            events[0]["resource_id"], "sym:Foo",
7829            "declared resource_id must reach trace.jsonl verbatim, unparsed"
7830        );
7831    }
7832
7833    #[tokio::test]
7834    async fn tool_call_omits_undeclared_resource_id() {
7835        let dir = tempfile::tempdir().unwrap();
7836        let mut w = make_writer(dir.path()).await;
7837        w.write_tool_call(
7838            0,
7839            "bash".to_string(),
7840            None,
7841            serde_json::json!({"command": "echo hi"}),
7842            10,
7843            "hi\n",
7844            3,
7845            5,
7846            "ok".to_string(),
7847            None,
7848            None,
7849            None,
7850        )
7851        .await
7852        .unwrap();
7853        w.flush().await.unwrap();
7854
7855        let events = read_events(dir.path());
7856        assert!(
7857            events[0].get("resource_id").is_none(),
7858            "resource_id must be omitted entirely (not null) when the tool declared nothing"
7859        );
7860    }
7861
7862    #[tokio::test]
7863    async fn shell_fields() {
7864        let dir = tempfile::tempdir().unwrap();
7865        let mut w = make_writer(dir.path()).await;
7866        w.write_shell(
7867            0,
7868            "/usr/bin/echo".to_string(),
7869            "echo hi".to_string(),
7870            0,
7871            7,
7872            0,
7873            10,
7874            None,
7875        )
7876        .await
7877        .unwrap();
7878        w.flush().await.unwrap();
7879
7880        let events = read_events(dir.path());
7881        let e = &events[0];
7882        assert_eq!(e["event_type"], "shell");
7883        // `binary` names what ran; `command` still carries only the argument list, so
7884        // neither can be derived from the other.
7885        assert_eq!(e["binary"], "/usr/bin/echo");
7886        assert_eq!(e["command"], "echo hi");
7887        assert_eq!(e["exit_code"], 0);
7888        assert_eq!(e["stdout_bytes"], 7);
7889        assert_eq!(e["stderr_bytes"], 0);
7890        assert_eq!(e["duration_ms"], 10);
7891    }
7892
7893    #[tokio::test]
7894    async fn compaction_fields() {
7895        let dir = tempfile::tempdir().unwrap();
7896        let mut w = make_writer(dir.path()).await;
7897        w.write_compaction(3, 80000, OccupancyBasis::Actual, 20000)
7898            .await
7899            .unwrap();
7900        w.write_compaction(4, 30000, OccupancyBasis::Estimate, 10000)
7901            .await
7902            .unwrap();
7903        w.flush().await.unwrap();
7904
7905        let events = read_events(dir.path());
7906        let e = &events[0];
7907        assert_eq!(e["event_type"], "compaction");
7908        assert_eq!(e["turn"], 3);
7909        assert_eq!(e["tokens_before"], 80000);
7910        assert_eq!(e["tokens_before_source"], "actual");
7911        assert_eq!(e["tokens_after"], 20000);
7912        assert_eq!(events[1]["tokens_before_source"], "estimate");
7913    }
7914
7915    #[tokio::test]
7916    async fn hook_dispatch_error_fields() {
7917        let dir = tempfile::tempdir().unwrap();
7918        let mut w = make_writer(dir.path()).await;
7919        w.write_hook_dispatch_error("my-hook", "on-tool-call", "write-manifests")
7920            .await
7921            .unwrap();
7922        w.flush().await.unwrap();
7923
7924        let events = read_events(dir.path());
7925        let e = &events[0];
7926        assert_eq!(e["event_type"], "hook_dispatch_error");
7927        assert_eq!(e["session_id"], "test-session-id");
7928        assert_eq!(e["hook_name"], "my-hook");
7929        assert_eq!(e["event"], "on-tool-call");
7930        assert_eq!(e["arm"], "write-manifests");
7931        assert!(e["timestamp"].as_u64().unwrap() > 0);
7932        // snake_case checks
7933        assert!(e.get("hookName").is_none(), "must use snake_case");
7934    }
7935
7936    #[tokio::test]
7937    async fn session_end_fields_and_snake_case() {
7938        let dir = tempfile::tempdir().unwrap();
7939        let mut w = make_writer(dir.path()).await;
7940        w.write_inference(
7941            0,
7942            Some(100),
7943            Some(50),
7944            "tool_call".to_string(),
7945            Some("tool_call"),
7946            None,
7947            None,
7948            None,
7949            Vec::new(),
7950            None,
7951            None,
7952        )
7953        .await
7954        .unwrap();
7955        w.write_tool_call(
7956            0,
7957            "bash".to_string(),
7958            None,
7959            serde_json::json!({"command": "ls"}),
7960            10,
7961            "file.txt\n",
7962            20,
7963            5,
7964            "ok".to_string(),
7965            None,
7966            None,
7967            None,
7968        )
7969        .await
7970        .unwrap();
7971        w.write_shell(0, "/bin/ls".to_string(), "ls".to_string(), 0, 3, 0, 2, None)
7972            .await
7973            .unwrap();
7974        w.write_session_end("ok").await.unwrap();
7975        w.flush().await.unwrap();
7976
7977        let events = read_events(dir.path());
7978        let se = events.last().unwrap();
7979        assert_eq!(se["event_type"], "session_end");
7980        assert_eq!(se["total_turns"], 1);
7981        assert_eq!(se["total_input_tokens"], 100);
7982        assert_eq!(se["total_output_tokens"], 50);
7983        assert_eq!(se["total_tool_calls"], 1);
7984        assert_eq!(se["total_shell_calls"], 1);
7985        assert_eq!(se["exit_status"], "ok");
7986        assert!(se["duration_ms"].as_u64().is_some());
7987        // snake_case checks
7988        assert!(se.get("exitStatus").is_none(), "must use snake_case");
7989        assert!(se.get("totalTurns").is_none(), "must use snake_case");
7990    }
7991
7992    #[tokio::test]
7993    async fn write_session_end_if_not_ended_is_noop_when_already_ended() {
7994        let dir = tempfile::tempdir().unwrap();
7995        let mut w = make_writer(dir.path()).await;
7996        w.write_session_start(Some(10), None, vec![]).await.unwrap();
7997        w.write_session_end("ok").await.unwrap();
7998        w.write_session_end_if_not_ended("failed").await.unwrap();
7999        w.flush().await.unwrap();
8000
8001        let events = read_events(dir.path());
8002        assert_eq!(
8003            events
8004                .iter()
8005                .filter(|e| e["event_type"] == "session_end")
8006                .count(),
8007            1,
8008            "session_end should appear exactly once"
8009        );
8010    }
8011
8012    #[tokio::test]
8013    async fn write_session_end_if_not_ended_writes_when_started_not_ended() {
8014        let dir = tempfile::tempdir().unwrap();
8015        let mut w = make_writer(dir.path()).await;
8016        w.write_session_start(Some(10), None, vec![]).await.unwrap();
8017        // Simulate error path — session_end_if_not_ended in launch_session
8018        w.write_session_end_if_not_ended("failed").await.unwrap();
8019        w.flush().await.unwrap();
8020
8021        let events = read_events(dir.path());
8022        let se = events
8023            .iter()
8024            .find(|e| e["event_type"] == "session_end")
8025            .unwrap();
8026        assert_eq!(se["exit_status"], "failed");
8027    }
8028
8029    #[tokio::test]
8030    async fn no_session_end_written_when_session_never_started() {
8031        let dir = tempfile::tempdir().unwrap();
8032        let mut w = make_writer(dir.path()).await;
8033        // session_start never written — fallback should be no-op
8034        w.write_session_end_if_not_ended("failed").await.unwrap();
8035        w.flush().await.unwrap();
8036
8037        let events = read_events(dir.path());
8038        assert!(
8039            events.iter().all(|e| e["event_type"] != "session_end"),
8040            "session_end must not appear when session_start was never written"
8041        );
8042    }
8043
8044    #[tokio::test]
8045    async fn session_id_on_every_event() {
8046        let dir = tempfile::tempdir().unwrap();
8047        let mut w = make_writer(dir.path()).await;
8048        w.write_session_start(Some(10), None, vec!["bash".to_string()])
8049            .await
8050            .unwrap();
8051        w.write_inference(
8052            0,
8053            Some(10),
8054            Some(5),
8055            "end_turn".to_string(),
8056            Some("end_turn"),
8057            None,
8058            None,
8059            None,
8060            Vec::new(),
8061            None,
8062            None,
8063        )
8064        .await
8065        .unwrap();
8066        w.write_session_end("ok").await.unwrap();
8067        w.flush().await.unwrap();
8068
8069        let events = read_events(dir.path());
8070        for e in &events {
8071            assert_eq!(e["session_id"], "test-session-id");
8072        }
8073    }
8074
8075    #[tokio::test]
8076    async fn counts_match_per_event_sum() {
8077        let dir = tempfile::tempdir().unwrap();
8078        let mut w = make_writer(dir.path()).await;
8079        w.write_session_start(Some(10), None, vec![]).await.unwrap();
8080        w.write_inference(
8081            0,
8082            Some(50),
8083            Some(25),
8084            "tool_call".to_string(),
8085            Some("tool_call"),
8086            Some("bash".to_string()),
8087            None,
8088            None,
8089            vec!["msg_0000000000000000000000000000000a".to_string()],
8090            None,
8091            None,
8092        )
8093        .await
8094        .unwrap();
8095        w.write_tool_call(
8096            0,
8097            "bash".to_string(),
8098            None,
8099            serde_json::json!({"command": "echo"}),
8100            5,
8101            "ok",
8102            10,
8103            3,
8104            "ok".to_string(),
8105            None,
8106            None,
8107            None,
8108        )
8109        .await
8110        .unwrap();
8111        w.write_shell(
8112            0,
8113            "/bin/echo".to_string(),
8114            "echo".to_string(),
8115            0,
8116            4,
8117            0,
8118            1,
8119            None,
8120        )
8121        .await
8122        .unwrap();
8123        w.write_inference(
8124            1,
8125            Some(60),
8126            Some(30),
8127            "end_turn".to_string(),
8128            Some("end_turn"),
8129            None,
8130            None,
8131            None,
8132            Vec::new(),
8133            None,
8134            None,
8135        )
8136        .await
8137        .unwrap();
8138        w.write_session_end("ok").await.unwrap();
8139        w.flush().await.unwrap();
8140
8141        let events = read_events(dir.path());
8142        let inference_count = events
8143            .iter()
8144            .filter(|e| e["event_type"] == "inference")
8145            .count();
8146        let tool_count = events
8147            .iter()
8148            .filter(|e| e["event_type"] == "tool_call")
8149            .count();
8150        let shell_count = events.iter().filter(|e| e["event_type"] == "shell").count();
8151
8152        let se = events
8153            .iter()
8154            .find(|e| e["event_type"] == "session_end")
8155            .unwrap();
8156        assert_eq!(
8157            se["total_turns"].as_u64().unwrap() as usize,
8158            inference_count
8159        );
8160        assert_eq!(
8161            se["total_tool_calls"].as_u64().unwrap() as usize,
8162            tool_count
8163        );
8164        assert_eq!(
8165            se["total_shell_calls"].as_u64().unwrap() as usize,
8166            shell_count
8167        );
8168    }
8169
8170    #[tokio::test]
8171    async fn tool_call_input_always_present() {
8172        let dir = tempfile::tempdir().unwrap();
8173        let mut w = make_writer(dir.path()).await; // TraceCapture::Meta
8174        w.write_tool_call(
8175            0,
8176            "bash".to_string(),
8177            None,
8178            serde_json::json!({"command": "ls -la"}),
8179            20,
8180            "total 8\n",
8181            80,
8182            12,
8183            "ok".to_string(),
8184            None,
8185            None,
8186            None,
8187        )
8188        .await
8189        .unwrap();
8190        w.flush().await.unwrap();
8191
8192        let events = read_events(dir.path());
8193        let e = &events[0];
8194        assert_eq!(e["event_type"], "tool_call");
8195        assert_eq!(e["input"]["command"], "ls -la");
8196        assert!(e.get("input").is_some(), "input must always be present");
8197    }
8198
8199    /// The `tool_call` record `w` writes for one failed call carrying `error`, read back.
8200    async fn tool_call_with_error(
8201        dir: &std::path::Path,
8202        capture: TraceCapture,
8203        error: Option<&str>,
8204    ) -> Value {
8205        let mut w = make_writer_with_opts(dir, capture).await;
8206        w.write_tool_call(
8207            0,
8208            "tool-fixture".to_string(),
8209            Some("toolu_1".to_string()),
8210            serde_json::json!({}),
8211            2,
8212            "shell execution failed early",
8213            28,
8214            3,
8215            "error".to_string(),
8216            None,
8217            None,
8218            error,
8219        )
8220        .await
8221        .unwrap();
8222        w.flush().await.unwrap();
8223        read_events(dir).remove(0)
8224    }
8225
8226    #[tokio::test]
8227    async fn tool_call_error_is_written_without_output_under_meta() {
8228        let dir = tempfile::tempdir().unwrap();
8229        let text = "sandbox: shell enforcement setup failed before exec: Landlock: \
8230                    Too many open files (os error 24)";
8231        let e = tool_call_with_error(dir.path(), TraceCapture::Meta, Some(text)).await;
8232        assert_eq!(e["error"], text);
8233        assert!(e.get("output").is_none(), "meta stores no output: {e}");
8234    }
8235
8236    #[tokio::test]
8237    async fn tool_call_error_is_written_under_every_capture() {
8238        for capture in [
8239            TraceCapture::None,
8240            TraceCapture::Meta,
8241            TraceCapture::Content,
8242        ] {
8243            let dir = tempfile::tempdir().unwrap();
8244            let e = tool_call_with_error(dir.path(), capture, Some("spawn failed")).await;
8245            assert_eq!(e["error"], "spawn failed", "{capture:?}: {e}");
8246        }
8247    }
8248
8249    #[tokio::test]
8250    async fn tool_call_error_is_capped_on_a_char_boundary() {
8251        let dir = tempfile::tempdir().unwrap();
8252        // 3,000 characters: one ASCII byte, then two-byte characters, so the cap's byte offset
8253        // falls inside a character.
8254        let long = format!("a{}", "é".repeat(2_999));
8255        assert_eq!(long.chars().count(), 3_000);
8256        let e = tool_call_with_error(dir.path(), TraceCapture::Meta, Some(&long)).await;
8257        let error = e["error"].as_str().unwrap();
8258        assert!(error.len() <= MAX_TASK_FAILURE_REASON_BYTES);
8259        assert!(error.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
8260        assert!(long.starts_with(error));
8261    }
8262
8263    #[tokio::test]
8264    async fn tool_call_error_redacts_a_peer_handle() {
8265        let dir = tempfile::tempdir().unwrap();
8266        let token = crate::peer_handoff::mint(
8267            &crate::peer_handoff::PeerMintKey::generate().unwrap(),
8268            &crate::peer_handoff::HandlePayload {
8269                v: 1,
8270                iss: "ses_1".to_string(),
8271                p: "report.md".to_string(),
8272                exp: timestamp_ms() + 3_600_000,
8273                n: "0123456789abcdef0123456789abcdef".to_string(),
8274            },
8275            "a@b",
8276        )
8277        .unwrap();
8278        let text = format!("fetch-peer-file {token} failed");
8279        let e = tool_call_with_error(dir.path(), TraceCapture::Meta, Some(&text)).await;
8280        let id = crate::peer_handoff::handle_id(&token);
8281        assert_eq!(e["error"], format!("fetch-peer-file <handle:{id}> failed"));
8282    }
8283
8284    #[tokio::test]
8285    async fn tool_call_without_an_error_has_no_error_key() {
8286        let dir = tempfile::tempdir().unwrap();
8287        let e = tool_call_with_error(dir.path(), TraceCapture::Meta, None).await;
8288        assert!(e.get("error").is_none(), "{e}");
8289    }
8290
8291    #[tokio::test]
8292    async fn tool_call_output_absent_by_default() {
8293        let dir = tempfile::tempdir().unwrap();
8294        let mut w = make_writer(dir.path()).await; // TraceCapture::Meta
8295        w.write_tool_call(
8296            0,
8297            "bash".to_string(),
8298            None,
8299            serde_json::json!({"command": "ls"}),
8300            10,
8301            "file.txt\n",
8302            90,
8303            8,
8304            "ok".to_string(),
8305            None,
8306            None,
8307            None,
8308        )
8309        .await
8310        .unwrap();
8311        w.flush().await.unwrap();
8312
8313        let events = read_events(dir.path());
8314        let e = &events[0];
8315        assert!(
8316            e.get("output").is_none(),
8317            "output must be absent under a capture mode that stores no bodies"
8318        );
8319        assert_eq!(e["output_bytes"], 90, "output_bytes must still be recorded");
8320    }
8321
8322    #[tokio::test]
8323    async fn skill_call_writes_skill_call_event() {
8324        let dir = tempfile::tempdir().unwrap();
8325        let mut w = make_writer(dir.path()).await;
8326        w.write_skill_call(
8327            2,
8328            "my-skill".to_string(),
8329            &LockOrigin::Operator,
8330            1024,
8331            8,
8332            "ok".to_string(),
8333        )
8334        .await
8335        .unwrap();
8336        w.flush().await.unwrap();
8337
8338        let events = read_events(dir.path());
8339        let e = &events[0];
8340        assert_eq!(e["event_type"], "skill_call");
8341        assert_eq!(e["turn"], 2);
8342        assert_eq!(e["skill_name"], "my-skill");
8343        assert_eq!(e["output_bytes"], 1024);
8344        assert_eq!(e["duration_ms"], 8);
8345        assert_eq!(e["status"], "ok");
8346    }
8347
8348    #[tokio::test]
8349    async fn skill_call_does_not_increment_total_tool_calls() {
8350        let dir = tempfile::tempdir().unwrap();
8351        let mut w = make_writer(dir.path()).await;
8352        w.write_session_start(Some(10), None, vec![]).await.unwrap();
8353        w.write_tool_call(
8354            0,
8355            "real-tool".to_string(),
8356            None,
8357            serde_json::json!({}),
8358            2,
8359            "data",
8360            4,
8361            5,
8362            "ok".to_string(),
8363            None,
8364            None,
8365            None,
8366        )
8367        .await
8368        .unwrap();
8369        w.write_skill_call(
8370            1,
8371            "my-skill".to_string(),
8372            &LockOrigin::Operator,
8373            512,
8374            3,
8375            "ok".to_string(),
8376        )
8377        .await
8378        .unwrap();
8379        w.write_session_end("ok").await.unwrap();
8380        w.flush().await.unwrap();
8381
8382        let events = read_events(dir.path());
8383        let se = events
8384            .iter()
8385            .find(|e| e["event_type"] == "session_end")
8386            .unwrap();
8387        assert_eq!(
8388            se["total_tool_calls"], 1,
8389            "skill_call must not inflate total_tool_calls"
8390        );
8391    }
8392
8393    #[tokio::test]
8394    async fn tool_call_output_present_when_opted_in() {
8395        let dir = tempfile::tempdir().unwrap();
8396        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
8397        w.write_tool_call(
8398            0,
8399            "bash".to_string(),
8400            None,
8401            serde_json::json!({"command": "echo hello"}),
8402            22,
8403            "hello\n",
8404            60,
8405            5,
8406            "ok".to_string(),
8407            None,
8408            None,
8409            None,
8410        )
8411        .await
8412        .unwrap();
8413        w.flush().await.unwrap();
8414
8415        let events = read_events(dir.path());
8416        let e = &events[0];
8417        assert_eq!(
8418            e["output"].as_str().unwrap(),
8419            "hello\n",
8420            "output must be present under capture: content"
8421        );
8422        assert_eq!(e["output_bytes"], 60);
8423    }
8424
8425    #[tokio::test]
8426    async fn task_end_carries_reopen_count() {
8427        let dir = tempfile::tempdir().unwrap();
8428        let mut w = make_writer(dir.path()).await;
8429        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8430            .await
8431            .unwrap();
8432        w.write_task_end("tsk_1", "ok", 2, Vec::new(), None, None)
8433            .await
8434            .unwrap();
8435        w.flush().await.unwrap();
8436
8437        let events = read_events(dir.path());
8438        let end = events
8439            .iter()
8440            .find(|e| e["event_type"] == "task_end")
8441            .unwrap();
8442        assert_eq!(end["task_id"], "tsk_1");
8443        assert_eq!(end["exit_status"], "ok");
8444        assert_eq!(end["reopen_count"], 2);
8445        assert_eq!(
8446            end["capped_turns"], 0,
8447            "always present, 0 when no turn was capped"
8448        );
8449    }
8450
8451    /// `task_end.capped_turns` counts the task's agent-loop `inference` lines with
8452    /// `stop_reason: "max_tokens"`, and a new task starts it at 0.
8453    #[tokio::test]
8454    async fn task_end_counts_capped_turns_per_task() {
8455        let dir = tempfile::tempdir().unwrap();
8456        let mut w = make_writer(dir.path()).await;
8457        for task in ["tsk_1", "tsk_2"] {
8458            w.write_task_start(task, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8459                .await
8460                .unwrap();
8461            let stops: &[&str] = if task == "tsk_1" {
8462                &["max_tokens", "tool_call", "max_tokens", "end_turn"]
8463            } else {
8464                &["end_turn"]
8465            };
8466            for (turn, stop) in stops.iter().enumerate() {
8467                let decision = if *stop == "max_tokens" {
8468                    "capped"
8469                } else {
8470                    stop
8471                };
8472                w.write_turn_inference(
8473                    u32::try_from(turn).unwrap(),
8474                    Some(10),
8475                    Some(5),
8476                    decision.to_string(),
8477                    Some(stop),
8478                    None,
8479                    None,
8480                    Vec::new(),
8481                    None,
8482                    None,
8483                    None,
8484                    RetryNote::default(),
8485                    None,
8486                )
8487                .await
8488                .unwrap();
8489            }
8490            w.write_task_end(task, "ok", 0, Vec::new(), None, None)
8491                .await
8492                .unwrap();
8493        }
8494        w.flush().await.unwrap();
8495
8496        let capped: Vec<_> = read_events(dir.path())
8497            .iter()
8498            .filter(|e| e["event_type"] == "task_end")
8499            .map(|e| e["capped_turns"].clone())
8500            .collect();
8501        assert_eq!(capped, vec![serde_json::json!(2), serde_json::json!(0)]);
8502    }
8503
8504    /// `task_end.keep_working` is the summary the runtime passes, `stopped_by` `null` when unset,
8505    /// and absent when the capsule does not set `lifecycle.keep_working`.
8506    #[tokio::test]
8507    async fn task_end_carries_keep_working_only_when_set() {
8508        let dir = tempfile::tempdir().unwrap();
8509        let mut w = make_writer(dir.path()).await;
8510        for (task_id, keep_working) in [
8511            (
8512                "tsk_1",
8513                Some(KeepWorkingSummary {
8514                    used: 2,
8515                    limit: 2,
8516                    stopped_by: Some("limit"),
8517                }),
8518            ),
8519            (
8520                "tsk_2",
8521                Some(KeepWorkingSummary {
8522                    used: 1,
8523                    limit: 5,
8524                    stopped_by: None,
8525                }),
8526            ),
8527            ("tsk_3", None),
8528        ] {
8529            w.write_task_start(task_id, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8530                .await
8531                .unwrap();
8532            w.write_task_end(task_id, "ok", 0, Vec::new(), keep_working, None)
8533                .await
8534                .unwrap();
8535        }
8536        w.flush().await.unwrap();
8537
8538        let events = read_events(dir.path());
8539        let ends: Vec<_> = events
8540            .iter()
8541            .filter(|e| e["event_type"] == "task_end")
8542            .collect();
8543        assert_eq!(
8544            ends[0]["keep_working"],
8545            serde_json::json!({"used": 2, "limit": 2, "stopped_by": "limit"})
8546        );
8547        assert_eq!(
8548            ends[1]["keep_working"],
8549            serde_json::json!({"used": 1, "limit": 5, "stopped_by": null})
8550        );
8551        assert!(ends[2].get("keep_working").is_none(), "{}", ends[2]);
8552    }
8553
8554    /// One `keep_working` record per continuation, parented on the task, and the session-status
8555    /// count follows it.
8556    #[tokio::test]
8557    async fn keep_working_record_carries_its_fields_and_feeds_session_status() {
8558        let dir = tempfile::tempdir().unwrap();
8559        let mut w = make_writer(dir.path()).await;
8560        let status = std::sync::Arc::new(crate::session_status::SessionStatus::new(
8561            std::time::Instant::now(),
8562            false,
8563            None,
8564            None,
8565            0,
8566            Some(3),
8567            None,
8568        ));
8569        w.set_session_status(std::sync::Arc::clone(&status));
8570        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8571            .await
8572            .unwrap();
8573        w.write_keep_working("tsk_1", 1, 3, Some(4), 66)
8574            .await
8575            .unwrap();
8576        w.write_keep_working("tsk_1", 2, 3, None, 66).await.unwrap();
8577        w.flush().await.unwrap();
8578
8579        let events = read_events(dir.path());
8580        let start = events
8581            .iter()
8582            .find(|e| e["event_type"] == "task_start")
8583            .unwrap();
8584        let records: Vec<_> = events
8585            .iter()
8586            .filter(|e| e["event_type"] == "keep_working")
8587            .collect();
8588        assert_eq!(records.len(), 2);
8589        for (record, (number, turns)) in records
8590            .iter()
8591            .zip([(1, serde_json::json!(4)), (2, serde_json::Value::Null)])
8592        {
8593            assert_eq!(record["task_id"], "tsk_1");
8594            assert_eq!(record["number"], number);
8595            assert_eq!(record["limit"], 3);
8596            assert_eq!(record["turns_remaining"], turns);
8597            assert_eq!(record["message_bytes"], 66);
8598            assert_eq!(record["parent_id"], start["event_id"]);
8599            assert!(record["event_id"].is_string());
8600        }
8601        let spend = crate::session_status::SpendReading {
8602            used: 0,
8603            ceiling: None,
8604            machine: None,
8605        };
8606        assert_eq!(
8607            serde_json::to_value(status.snapshot(spend)).unwrap()["keep_working"],
8608            serde_json::json!({"used": 2, "left": 1})
8609        );
8610    }
8611
8612    /// `task_end.done_check` is the summary the runtime passes and is absent with the option off.
8613    #[tokio::test]
8614    async fn task_end_carries_done_check_only_when_set() {
8615        let dir = tempfile::tempdir().unwrap();
8616        let mut w = make_writer(dir.path()).await;
8617        for (task_id, done_check) in [
8618            (
8619                "tsk_1",
8620                Some(DoneCheckSummary {
8621                    runs: 3,
8622                    reopens: 2,
8623                    limit: 2,
8624                    met: Some(false),
8625                    stopped_by: Some("limit"),
8626                }),
8627            ),
8628            (
8629                "tsk_2",
8630                Some(DoneCheckSummary {
8631                    runs: 0,
8632                    reopens: 0,
8633                    limit: 1,
8634                    met: None,
8635                    stopped_by: None,
8636                }),
8637            ),
8638            ("tsk_3", None),
8639        ] {
8640            w.write_task_start(task_id, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8641                .await
8642                .unwrap();
8643            w.write_task_end(task_id, "ok", 0, Vec::new(), None, done_check)
8644                .await
8645                .unwrap();
8646        }
8647        w.flush().await.unwrap();
8648
8649        let events = read_events(dir.path());
8650        let ends: Vec<_> = events
8651            .iter()
8652            .filter(|e| e["event_type"] == "task_end")
8653            .collect();
8654        assert_eq!(
8655            ends[0]["done_check"],
8656            serde_json::json!({"runs": 3, "reopens": 2, "limit": 2, "met": false,
8657                               "stopped_by": "limit"})
8658        );
8659        assert_eq!(
8660            ends[1]["done_check"],
8661            serde_json::json!({"runs": 0, "reopens": 0, "limit": 1, "met": null,
8662                               "stopped_by": null})
8663        );
8664        assert!(ends[2].get("done_check").is_none(), "{}", ends[2]);
8665    }
8666
8667    /// One `done_check` record per run, parented on the task, with its fields in the documented
8668    /// order; a reopening run feeds the session-status count and a non-reopening one does not.
8669    #[tokio::test]
8670    async fn done_check_record_carries_its_fields_and_feeds_session_status() {
8671        let dir = tempfile::tempdir().unwrap();
8672        let mut w = make_writer(dir.path()).await;
8673        let status = std::sync::Arc::new(crate::session_status::SessionStatus::new(
8674            std::time::Instant::now(),
8675            false,
8676            None,
8677            None,
8678            0,
8679            None,
8680            Some(3),
8681        ));
8682        w.set_session_status(std::sync::Arc::clone(&status));
8683        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8684            .await
8685            .unwrap();
8686        let mut run = DoneCheckRun {
8687            task_id: "tsk_1",
8688            number: 1,
8689            command: "bash -c 'exit 1'",
8690            outcome: "failed",
8691            exit_code: Some(1),
8692            duration_ms: 12,
8693            stdout_bytes: 5,
8694            stderr_bytes: 0,
8695            truncated: false,
8696            resource_limit: None,
8697            reopened: true,
8698            reopens_left: 2,
8699            turns_remaining: Some(4),
8700        };
8701        w.write_done_check(&run, 1).await.unwrap();
8702        run.number = 2;
8703        run.outcome = "timed_out";
8704        run.exit_code = None;
8705        run.reopened = false;
8706        run.turns_remaining = None;
8707        w.write_done_check(&run, 1).await.unwrap();
8708        w.flush().await.unwrap();
8709
8710        let text = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
8711        let line = text
8712            .lines()
8713            .find(|line| line.contains("\"done_check\""))
8714            .unwrap();
8715        let order: Vec<usize> = [
8716            "event_type",
8717            "event_id",
8718            "parent_id",
8719            "session_id",
8720            "timestamp",
8721            "task_id",
8722            "number",
8723            "command",
8724            "outcome",
8725            "exit_code",
8726            "duration_ms",
8727            "stdout_bytes",
8728            "stderr_bytes",
8729            "truncated",
8730            "resource_limit",
8731            "reopened",
8732            "reopens_left",
8733            "turns_remaining",
8734        ]
8735        .iter()
8736        .map(|key| line.find(&format!("\"{key}\":")).unwrap())
8737        .collect();
8738        assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "{line}");
8739
8740        let events = read_events(dir.path());
8741        let start = events
8742            .iter()
8743            .find(|e| e["event_type"] == "task_start")
8744            .unwrap();
8745        let records: Vec<_> = events
8746            .iter()
8747            .filter(|e| e["event_type"] == "done_check")
8748            .collect();
8749        assert_eq!(records.len(), 2);
8750        assert_eq!(records[0]["parent_id"], start["event_id"]);
8751        assert_eq!(records[0]["command"], "bash -c 'exit 1'");
8752        assert_eq!(records[0]["exit_code"], 1);
8753        assert_eq!(records[0]["reopened"], true);
8754        assert_eq!(records[0]["turns_remaining"], 4);
8755        assert_eq!(records[1]["outcome"], "timed_out");
8756        assert_eq!(records[1]["exit_code"], serde_json::Value::Null);
8757        assert_eq!(records[1]["resource_limit"], serde_json::Value::Null);
8758        assert_eq!(records[1]["turns_remaining"], serde_json::Value::Null);
8759        let spend = crate::session_status::SpendReading {
8760            used: 0,
8761            ceiling: None,
8762            machine: None,
8763        };
8764        assert_eq!(
8765            serde_json::to_value(status.snapshot(spend)).unwrap()["done_check"],
8766            serde_json::json!({"used": 1, "left": 2})
8767        );
8768    }
8769
8770    /// `task_end` names the background commands still running, and has no such field when there
8771    /// were none.
8772    #[tokio::test]
8773    async fn task_end_names_the_commands_still_running() {
8774        let dir = tempfile::tempdir().unwrap();
8775        let mut w = make_writer(dir.path()).await;
8776        for (task_id, running) in [
8777            ("tsk_1", vec!["wrk_0a".to_string(), "wrk_0b".to_string()]),
8778            ("tsk_2", Vec::new()),
8779        ] {
8780            w.write_task_start(task_id, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8781                .await
8782                .unwrap();
8783            w.write_task_end(task_id, "ok", 0, running, None, None)
8784                .await
8785                .unwrap();
8786        }
8787        w.flush().await.unwrap();
8788
8789        let events = read_events(dir.path());
8790        let ends: Vec<_> = events
8791            .iter()
8792            .filter(|e| e["event_type"] == "task_end")
8793            .collect();
8794        assert_eq!(
8795            ends[0]["running_commands"],
8796            serde_json::json!(["wrk_0a", "wrk_0b"])
8797        );
8798        assert!(ends[1].get("running_commands").is_none(), "{}", ends[1]);
8799    }
8800
8801    /// The two writers of `shell_completed` build one record: a completion a `background-command`
8802    /// call delivered carries no `completion_task_id`, one queued as a task names it, and both
8803    /// carry when the command exited and which way it reached the agent.
8804    #[tokio::test]
8805    async fn shell_completed_names_its_delivery_and_exit_time() {
8806        let dir = tempfile::tempdir().unwrap();
8807        let mut w = make_writer(dir.path()).await;
8808        let appender = ResourceTraceAppender::open(
8809            &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
8810            "test-session-id".to_string(),
8811            w.session_event_id().to_string(),
8812        )
8813        .await
8814        .unwrap();
8815        let completion = crate::detached::DetachedCompletion {
8816            work_id: "wrk_0a".to_string(),
8817            binary: "/usr/bin/bash".to_string(),
8818            command: "sleep 3".to_string(),
8819            exit_code: 0,
8820            duration_ms: 3_000,
8821            exited_at_ms: 1_750,
8822            output_path: "/records/logs/wrk_0a.log".to_string(),
8823            output_bytes: 10,
8824            resource_limit: None,
8825            output_holders: crate::shell::OutputHolders::Closed,
8826            context_id: "ctx_1".to_string(),
8827            provenance: crate::origin::TaskProvenance::derive(
8828                crate::origin::TaskOrigin::Completion,
8829                None,
8830            ),
8831            error: None,
8832        };
8833        w.write_session_start(None, None, Vec::new()).await.unwrap();
8834        w.flush().await.unwrap();
8835        appender.write_shell_completed(&completion).await;
8836        w.write_shell_completed(&completion, "tsk_2").await.unwrap();
8837        w.flush().await.unwrap();
8838
8839        let events = read_events(dir.path());
8840        let completed: Vec<_> = events
8841            .iter()
8842            .filter(|e| e["event_type"] == "shell_completed")
8843            .collect();
8844        assert_eq!(completed.len(), 2);
8845        assert_eq!(completed[0]["delivered_via"], "background_command");
8846        assert!(
8847            completed[0].get("completion_task_id").is_none(),
8848            "{}",
8849            completed[0]
8850        );
8851        assert_eq!(completed[1]["delivered_via"], "task");
8852        assert_eq!(completed[1]["completion_task_id"], "tsk_2");
8853        for record in &completed {
8854            assert_eq!(record["exited_at_ms"], 1_750);
8855            assert_eq!(record["work_id"], "wrk_0a");
8856            assert_eq!(record["status"], "ok");
8857            assert_eq!(record["duration_ms"], 3_000);
8858            assert_eq!(record["session_id"], "test-session-id");
8859            assert_eq!(record["parent_id"].as_str(), Some(w.session_event_id()));
8860        }
8861    }
8862
8863    #[tokio::test]
8864    async fn task_reopened_names_hook_reason_and_ordinal() {
8865        let dir = tempfile::tempdir().unwrap();
8866        let mut w = make_writer(dir.path()).await;
8867        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8868            .await
8869            .unwrap();
8870        w.write_task_reopened(
8871            "tsk_1",
8872            "gatekeeper",
8873            "tests still fail",
8874            1,
8875            REOPEN_CONTEXT_CONTINUED,
8876            Some(7),
8877        )
8878        .await
8879        .unwrap();
8880        w.flush().await.unwrap();
8881
8882        let events = read_events(dir.path());
8883        let re = events
8884            .iter()
8885            .find(|e| e["event_type"] == "task_reopened")
8886            .unwrap();
8887        assert_eq!(re["task_id"], "tsk_1");
8888        assert_eq!(re["hook_name"], "gatekeeper");
8889        assert_eq!(re["reason"], "tests still fail");
8890        assert_eq!(re["reopen_number"], 1);
8891        assert_eq!(re["attempt_context"], "continued");
8892        assert_eq!(re["turns_remaining"], 7);
8893
8894        w.write_task_reopened(
8895            "tsk_1",
8896            "gatekeeper",
8897            "again",
8898            2,
8899            REOPEN_CONTEXT_CONTINUED,
8900            None,
8901        )
8902        .await
8903        .unwrap();
8904        w.flush().await.unwrap();
8905        let uncapped = read_events(dir.path())
8906            .into_iter()
8907            .filter(|e| e["event_type"] == "task_reopened")
8908            .nth(1)
8909            .unwrap();
8910        assert!(uncapped
8911            .get("turns_remaining")
8912            .is_some_and(serde_json::Value::is_null));
8913        assert!(re["timestamp"].as_u64().unwrap() > 0);
8914    }
8915
8916    #[tokio::test]
8917    async fn task_continued_names_the_round_under_the_task_node() {
8918        let dir = tempfile::tempdir().unwrap();
8919        let mut w = make_writer(dir.path()).await;
8920        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8921            .await
8922            .unwrap();
8923        w.write_task_continued(
8924            "tsk_1",
8925            1,
8926            &["mcl_b".to_string(), "mcl_a".to_string()],
8927            &["dlg_1".to_string()],
8928            1234,
8929            Some(8),
8930        )
8931        .await
8932        .unwrap();
8933        w.flush().await.unwrap();
8934
8935        let events = read_events(dir.path());
8936        let start = events
8937            .iter()
8938            .find(|e| e["event_type"] == "task_start")
8939            .unwrap();
8940        let continued = events
8941            .iter()
8942            .find(|e| e["event_type"] == "task_continued")
8943            .unwrap();
8944        assert_eq!(continued["parent_id"], start["event_id"]);
8945        assert_eq!(continued["task_id"], "tsk_1");
8946        assert_eq!(continued["continuation_number"], 1);
8947        assert_eq!(
8948            continued["member_calls"],
8949            serde_json::json!(["mcl_b", "mcl_a"])
8950        );
8951        assert_eq!(continued["delegations"], serde_json::json!(["dlg_1"]));
8952        assert_eq!(continued["waited_ms"], 1234);
8953        assert_eq!(continued["turns_remaining"], 8);
8954    }
8955
8956    /// `task_turns()` reports the cumulative per-task turn count the reopen loop reads to
8957    /// compute the remaining `max_turns` budget: it advances with each `write_inference`
8958    /// and resets on `write_task_start`.
8959    #[tokio::test]
8960    async fn task_turns_accessor_tracks_and_resets() {
8961        let dir = tempfile::tempdir().unwrap();
8962        let mut w = make_writer(dir.path()).await;
8963        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8964            .await
8965            .unwrap();
8966        assert_eq!(w.task_turns(), 0);
8967        w.write_inference(
8968            0,
8969            Some(10),
8970            Some(5),
8971            "end_turn".to_string(),
8972            Some("end_turn"),
8973            None,
8974            None,
8975            None,
8976            Vec::new(),
8977            None,
8978            None,
8979        )
8980        .await
8981        .unwrap();
8982        w.write_inference(
8983            1,
8984            Some(10),
8985            Some(5),
8986            "end_turn".to_string(),
8987            Some("end_turn"),
8988            None,
8989            None,
8990            None,
8991            Vec::new(),
8992            None,
8993            None,
8994        )
8995        .await
8996        .unwrap();
8997        assert_eq!(w.task_turns(), 2);
8998        // A new task resets the per-task counter.
8999        w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), 3, &[], &[])
9000            .await
9001            .unwrap();
9002        assert_eq!(w.task_turns(), 0);
9003    }
9004
9005    /// `task_agent_turns()` counts the agent loop's own `inference` lines and not a hook's
9006    /// `run-inference` record, which `task_turns()` does count; both task boundaries reset it.
9007    #[tokio::test]
9008    async fn task_agent_turns_counts_agent_loop_inference_only() {
9009        let dir = tempfile::tempdir().unwrap();
9010        let mut w = make_writer(dir.path()).await;
9011        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9012            .await
9013            .unwrap();
9014        assert_eq!(w.task_agent_turns(), 0);
9015        let hook = InferenceOrigin {
9016            source: "hook:x".into(),
9017            model: "m".into(),
9018        };
9019        for (turn, origin) in [(0, None), (0, Some(&hook)), (1, None)] {
9020            w.write_inference(
9021                turn,
9022                Some(10),
9023                Some(5),
9024                "end_turn".to_string(),
9025                Some("end_turn"),
9026                None,
9027                origin,
9028                None,
9029                Vec::new(),
9030                None,
9031                None,
9032            )
9033            .await
9034            .unwrap();
9035        }
9036        assert_eq!(w.task_agent_turns(), 2);
9037        assert_eq!(w.task_turns(), 3);
9038        assert_eq!(w.task_hook_inferences(), 1);
9039        w.write_task_end("tsk_1", "completed", 0, Vec::new(), None, None)
9040            .await
9041            .unwrap();
9042        assert_eq!(
9043            w.task_agent_turns(),
9044            0,
9045            "a run outside a task frame starts at 0"
9046        );
9047        w.write_inference(
9048            0,
9049            Some(10),
9050            Some(5),
9051            "end_turn".to_string(),
9052            Some("end_turn"),
9053            None,
9054            None,
9055            None,
9056            Vec::new(),
9057            None,
9058            None,
9059        )
9060        .await
9061        .unwrap();
9062        w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), 3, &[], &[])
9063            .await
9064            .unwrap();
9065        assert_eq!(w.task_agent_turns(), 0);
9066    }
9067
9068    // ── Event identity and parenting ──────────────────────────────────────────
9069
9070    /// The full tree one task produces, walked the way a reader walks it: every line is
9071    /// identified, every non-null `parent_id` names a line already written, and the shape is
9072    /// session → task → turn → {tool_call, shell}.
9073    #[tokio::test]
9074    async fn every_event_carries_a_unique_id_and_parents_to_an_earlier_event() {
9075        let dir = tempfile::tempdir().unwrap();
9076        let mut w = make_writer(dir.path()).await;
9077        w.write_session_start(Some(10), None, vec!["bash".to_string()])
9078            .await
9079            .unwrap();
9080        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9081            .await
9082            .unwrap();
9083        w.write_inference(
9084            0,
9085            Some(10),
9086            Some(5),
9087            "tool_call".to_string(),
9088            Some("tool_call"),
9089            None,
9090            None,
9091            None,
9092            Vec::new(),
9093            None,
9094            None,
9095        )
9096        .await
9097        .unwrap();
9098        w.write_tool_call(
9099            0,
9100            "bash".to_string(),
9101            Some("toolu_1".to_string()),
9102            serde_json::json!({"command": "echo hi"}),
9103            10,
9104            "hi\n",
9105            3,
9106            5,
9107            "ok".to_string(),
9108            None,
9109            None,
9110            None,
9111        )
9112        .await
9113        .unwrap();
9114        w.write_shell(
9115            0,
9116            "/bin/bash".to_string(),
9117            "echo hi".to_string(),
9118            0,
9119            3,
9120            0,
9121            2,
9122            None,
9123        )
9124        .await
9125        .unwrap();
9126        w.write_artifact_pulled(&pulled(
9127            runtime_summary("pulled-style", ArtifactRuntime::Skill),
9128            "global store",
9129            None,
9130        ))
9131        .await
9132        .unwrap();
9133        w.write_task_end("tsk_1", "ok", 0, Vec::new(), None, None)
9134            .await
9135            .unwrap();
9136        w.write_session_end("ok").await.unwrap();
9137        w.flush().await.unwrap();
9138
9139        let events = read_events(dir.path());
9140        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
9141        for (i, event) in events.iter().enumerate() {
9142            let id = event["event_id"].as_str().unwrap();
9143            assert!(id.starts_with("evt_") && id.len() == 36, "event {i}: {id}");
9144            assert!(id[4..].chars().all(|c| c.is_ascii_hexdigit()), "event {i}");
9145            assert!(seen.insert(id.to_string()), "event {i} reuses {id}");
9146            assert!(
9147                event.get("parent_id").is_some(),
9148                "event {i} has no parent_id"
9149            );
9150            if let Some(parent) = event["parent_id"].as_str() {
9151                assert!(seen.contains(parent), "event {i} names dangling {parent}");
9152            }
9153        }
9154
9155        let by_type = |ty: &str| {
9156            events
9157                .iter()
9158                .find(|e| e["event_type"] == ty)
9159                .unwrap_or_else(|| panic!("no {ty}"))
9160                .clone()
9161        };
9162        let session = by_type("session_start");
9163        assert!(session["parent_id"].is_null());
9164        assert_eq!(session["event_id"], w.session_event_id());
9165
9166        let task = by_type("task_start");
9167        assert_eq!(task["parent_id"], session["event_id"]);
9168
9169        let turn = by_type("inference");
9170        assert_eq!(turn["parent_id"], task["event_id"]);
9171
9172        assert_eq!(by_type("tool_call")["parent_id"], turn["event_id"]);
9173        assert_eq!(by_type("shell")["parent_id"], turn["event_id"]);
9174        assert_eq!(by_type("task_end")["parent_id"], task["event_id"]);
9175        assert_eq!(by_type("session_end")["parent_id"], session["event_id"]);
9176        assert_eq!(by_type("artifact_pulled")["parent_id"], session["event_id"]);
9177    }
9178
9179    fn runtime_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
9180        InstalledArtifactSummary {
9181            name: name.to_string(),
9182            version: "1.0.0".to_string(),
9183            runtime,
9184            origin: LockOrigin::Runtime {
9185                session: "ses_puller".to_string(),
9186            },
9187        }
9188    }
9189
9190    fn operator_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
9191        InstalledArtifactSummary {
9192            origin: LockOrigin::Operator,
9193            ..runtime_summary(name, runtime)
9194        }
9195    }
9196
9197    /// A pull of `summary` from `fetched_from`, with `owner_id` when the place records one.
9198    fn pulled(
9199        summary: InstalledArtifactSummary,
9200        fetched_from: &str,
9201        owner_id: Option<u64>,
9202    ) -> crate::runtime::PulledArtifact {
9203        crate::runtime::PulledArtifact {
9204            summary,
9205            fetched_from: fetched_from.to_string(),
9206            owner_id,
9207            notes: Vec::new(),
9208        }
9209    }
9210
9211    /// `instruction_files` is always written: `null` without the key, the records in manifest
9212    /// order with it, and `[]` for an empty list.
9213    #[tokio::test]
9214    async fn session_start_instruction_files_is_null_without_the_key() {
9215        let work = tempfile::tempdir().unwrap();
9216        std::fs::write(work.path().join("AGENTS.md"), "x").unwrap();
9217        let read = crate::instruction_files::InstructionFiles::read(
9218            work.path(),
9219            &["AGENTS.md".to_string(), "CLAUDE.md".to_string()],
9220        );
9221        let empty = crate::instruction_files::InstructionFiles::read(work.path(), &[]);
9222        for (files, expected) in [
9223            (None, serde_json::Value::Null),
9224            (Some(&empty), serde_json::json!([])),
9225            (
9226                Some(&read),
9227                serde_json::json!([
9228                    {
9229                        "path": "AGENTS.md",
9230                        "status": "read",
9231                        "resolved": "AGENTS.md",
9232                        "bytes": 1,
9233                        "included_bytes": 1,
9234                        "truncated": false,
9235                        "sha256": murmur_artifact::sha256_hex(b"x"),
9236                    },
9237                    {"path": "CLAUDE.md", "status": "missing"},
9238                ]),
9239            ),
9240        ] {
9241            let dir = tempfile::tempdir().unwrap();
9242            let mut w = make_writer(dir.path()).await;
9243            w.set_instruction_files(files);
9244            w.write_session_start(Some(10), None, Vec::new())
9245                .await
9246                .unwrap();
9247            w.flush().await.unwrap();
9248            let events = read_events(dir.path());
9249            assert!(events[0].get("instruction_files").is_some());
9250            assert_eq!(events[0]["instruction_files"], expected);
9251        }
9252    }
9253
9254    fn harness_start_with_prompt(prompt: &str) -> HarnessStart {
9255        HarnessStart {
9256            driver: "local/fixture-process-driver".to_string(),
9257            driver_version: "0.1.0".to_string(),
9258            harness: "fixture".to_string(),
9259            tier: "landlock+seccomp".to_string(),
9260            binary: "/bin/true".to_string(),
9261            binary_source: "describe".to_string(),
9262            harness_version: None,
9263            version_tested: false,
9264            harness_session_id: "h".to_string(),
9265            session_mode: "new".to_string(),
9266            args_count: 0,
9267            env_names: Vec::new(),
9268            account_env_unset: Vec::new(),
9269            files: Vec::new(),
9270            bridge_tools: Vec::new(),
9271            stdin_bytes: 0,
9272            keep_stdin_open: false,
9273            system_prompt_sha256: murmur_artifact::sha256_hex(prompt.as_bytes()),
9274            system_prompt: prompt.to_string(),
9275        }
9276    }
9277
9278    /// `harness_start` names the prompt handed to the driver by its hash, never carries the text,
9279    /// and under content capture stores the text as that blob.
9280    #[tokio::test]
9281    async fn harness_start_records_the_system_prompt_hash_and_blob() {
9282        let prompt = "facts\n\nMANIFEST\n\n[Instruction file: AGENTS.md]\nx";
9283        let sha = murmur_artifact::sha256_hex(prompt.as_bytes());
9284        for capture in [TraceCapture::Meta, TraceCapture::Content] {
9285            let dir = tempfile::tempdir().unwrap();
9286            let mut w = make_writer_with_opts(dir.path(), capture).await;
9287            w.write_harness_start(harness_start_with_prompt(prompt))
9288                .await
9289                .unwrap();
9290            w.flush().await.unwrap();
9291            let event = &read_events(dir.path())[0];
9292            assert_eq!(event["event_type"], "harness_start");
9293            assert_eq!(event["system_prompt_sha256"], sha.as_str());
9294            assert!(event.get("system_prompt").is_none(), "{event}");
9295            let blob = dir.path().join(BLOB_DIR_NAME).join(&sha);
9296            if capture == TraceCapture::Content {
9297                assert_eq!(std::fs::read(&blob).unwrap(), prompt.as_bytes());
9298            } else {
9299                assert!(!blob.exists());
9300            }
9301        }
9302    }
9303
9304    /// `runtime_artifacts` is always written: `[]` for a session whose every artifact the operator
9305    /// pinned, including one that never called the setter.
9306    #[tokio::test]
9307    async fn runtime_origin_session_start_lists_nothing_for_an_operator_session() {
9308        for set in [false, true] {
9309            let dir = tempfile::tempdir().unwrap();
9310            let mut w = make_writer(dir.path()).await;
9311            if set {
9312                w.set_runtime_artifacts(&[
9313                    operator_summary("house-style", ArtifactRuntime::Skill),
9314                    operator_summary("fetcher", ArtifactRuntime::Tool),
9315                ]);
9316            }
9317            w.write_session_start(Some(10), None, Vec::new())
9318                .await
9319                .unwrap();
9320            w.flush().await.unwrap();
9321            let events = read_events(dir.path());
9322            assert_eq!(events[0]["runtime_artifacts"], serde_json::json!([]));
9323        }
9324    }
9325
9326    /// Only runtime-origin entries are listed, in the order staging recorded them, each naming
9327    /// its puller.
9328    #[tokio::test]
9329    async fn runtime_origin_session_start_lists_runtime_artifacts_in_order() {
9330        let dir = tempfile::tempdir().unwrap();
9331        let mut w = make_writer(dir.path()).await;
9332        w.set_runtime_artifacts(&[
9333            runtime_summary("zeta-tool", ArtifactRuntime::Tool),
9334            operator_summary("house-style", ArtifactRuntime::Skill),
9335            InstalledArtifactSummary {
9336                origin: LockOrigin::Runtime {
9337                    session: "ses_other".to_string(),
9338                },
9339                ..runtime_summary("alpha-style", ArtifactRuntime::Skill)
9340            },
9341        ]);
9342        w.write_session_start(Some(10), None, Vec::new())
9343            .await
9344            .unwrap();
9345        w.flush().await.unwrap();
9346
9347        let events = read_events(dir.path());
9348        assert_eq!(
9349            events[0]["runtime_artifacts"],
9350            serde_json::json!([
9351                {"name": "zeta-tool", "version": "1.0.0", "origin": "runtime",
9352                 "session": "ses_puller", "trust": "untrusted"},
9353                {"name": "alpha-style", "version": "1.0.0", "origin": "runtime",
9354                 "session": "ses_other", "trust": "untrusted"},
9355            ])
9356        );
9357    }
9358
9359    #[tokio::test]
9360    async fn runtime_origin_skill_call_carries_origin_and_trust() {
9361        let dir = tempfile::tempdir().unwrap();
9362        let mut w = make_writer(dir.path()).await;
9363        for origin in [
9364            LockOrigin::Operator,
9365            LockOrigin::Runtime {
9366                session: "ses_puller".to_string(),
9367            },
9368        ] {
9369            w.write_skill_call(0, "a-skill".to_string(), &origin, 1, 1, "ok".to_string())
9370                .await
9371                .unwrap();
9372        }
9373        w.flush().await.unwrap();
9374
9375        let events = read_events(dir.path());
9376        assert_eq!(events[0]["origin"], "operator");
9377        assert_eq!(events[0]["trust"], "trusted");
9378        assert_eq!(events[1]["origin"], "runtime");
9379        assert_eq!(events[1]["trust"], "untrusted");
9380    }
9381
9382    /// Every key is written, `session` included: `null` when the lock entry is the operator's.
9383    /// `owner_id` is written only when the place the payload came from records one.
9384    #[tokio::test]
9385    async fn runtime_origin_artifact_pulled_writes_every_key() {
9386        let dir = tempfile::tempdir().unwrap();
9387        let mut w = make_writer(dir.path()).await;
9388        w.write_artifact_pulled(&pulled(
9389            runtime_summary("pulled-style", ArtifactRuntime::Skill),
9390            "github:ana/artifacts",
9391            Some(4242),
9392        ))
9393        .await
9394        .unwrap();
9395        w.write_artifact_pulled(&pulled(
9396            operator_summary("fetcher", ArtifactRuntime::Tool),
9397            "project store",
9398            None,
9399        ))
9400        .await
9401        .unwrap();
9402        w.flush().await.unwrap();
9403
9404        let events = read_events(dir.path());
9405        let runtime = &events[0];
9406        assert_eq!(runtime["event_type"], "artifact_pulled");
9407        assert_eq!(runtime["name"], "pulled-style");
9408        assert_eq!(runtime["version"], "1.0.0");
9409        assert_eq!(runtime["runtime"], "skill");
9410        assert_eq!(runtime["origin"], "runtime");
9411        assert_eq!(runtime["session"], "ses_puller");
9412        assert_eq!(runtime["trust"], "untrusted");
9413        assert_eq!(runtime["fetched_from"], "github:ana/artifacts");
9414        assert_eq!(runtime["owner_id"], 4242);
9415        assert!(
9416            runtime["parent_id"].is_null(),
9417            "no session_start was written"
9418        );
9419
9420        let operator = &events[1];
9421        assert_eq!(operator["runtime"], "tool");
9422        assert_eq!(operator["origin"], "operator");
9423        assert!(operator.get("session").is_some_and(Value::is_null));
9424        assert_eq!(operator["trust"], "trusted");
9425        assert_eq!(operator["fetched_from"], "project store");
9426        assert!(operator.get("owner_id").is_none());
9427    }
9428
9429    /// A hook's `run-inference` record is a product of the turn it ran inside, not a turn of
9430    /// its own: it parents to the agent loop's inference and never replaces it as the node the
9431    /// turn's other events hang off.
9432    #[tokio::test]
9433    async fn hook_origin_inference_hangs_off_the_turn_rather_than_becoming_one() {
9434        let dir = tempfile::tempdir().unwrap();
9435        let mut w = make_writer(dir.path()).await;
9436        w.write_session_start(Some(10), None, Vec::new())
9437            .await
9438            .unwrap();
9439        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9440            .await
9441            .unwrap();
9442        w.write_inference(
9443            0,
9444            Some(10),
9445            Some(5),
9446            "tool_call".to_string(),
9447            Some("tool_call"),
9448            None,
9449            None,
9450            None,
9451            Vec::new(),
9452            None,
9453            None,
9454        )
9455        .await
9456        .unwrap();
9457        let origin = InferenceOrigin {
9458            source: "hook:gatekeeper".to_string(),
9459            model: "claude-test".to_string(),
9460        };
9461        w.write_inference(
9462            0,
9463            Some(1),
9464            Some(1),
9465            "end_turn".to_string(),
9466            // A hook's `run-inference` saw no provider stop reason, so it records none.
9467            None,
9468            None,
9469            Some(&origin),
9470            None,
9471            Vec::new(),
9472            None,
9473            None,
9474        )
9475        .await
9476        .unwrap();
9477        w.flush().await.unwrap();
9478
9479        let events = read_events(dir.path());
9480        let turn = &events[2];
9481        let hook = &events[3];
9482        assert!(turn["origin"].is_null());
9483        assert_eq!(hook["origin"], "hook:gatekeeper");
9484        assert_eq!(hook["parent_id"], turn["event_id"]);
9485        // The hook sent a message list the runtime never minted, so the key is absent rather
9486        // than empty.
9487        assert!(hook.get("message_ids").is_none(), "{hook}");
9488    }
9489
9490    /// A writer with no session frame behind it names no parent: naming one would dangle.
9491    #[tokio::test]
9492    async fn a2a_send_parents_to_null_without_a_session_frame_and_to_it_with_one() {
9493        let bare = tempfile::tempdir().unwrap();
9494        let mut w = make_writer(bare.path()).await;
9495        w.write_a2a_send(
9496            "http://peer",
9497            "msg_1",
9498            "tsk_1",
9499            "ctx_1",
9500            None,
9501            TrustClass::Untrusted,
9502        )
9503        .await
9504        .unwrap();
9505        w.flush().await.unwrap();
9506        assert!(read_events(bare.path())[0]["parent_id"].is_null());
9507
9508        let framed = tempfile::tempdir().unwrap();
9509        let mut w = make_writer(framed.path()).await;
9510        w.write_session_start(Some(1), None, Vec::new())
9511            .await
9512            .unwrap();
9513        w.write_a2a_send(
9514            "http://peer",
9515            "msg_1",
9516            "tsk_1",
9517            "ctx_1",
9518            None,
9519            TrustClass::Untrusted,
9520        )
9521        .await
9522        .unwrap();
9523        w.flush().await.unwrap();
9524        let events = read_events(framed.path());
9525        assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
9526    }
9527
9528    /// `task_rejected` carries every field and, with no task active, hangs off `session_start`.
9529    #[tokio::test]
9530    async fn task_rejected_names_the_task_source_cause_and_reason_under_the_session() {
9531        let dir = tempfile::tempdir().unwrap();
9532        let mut w = make_writer(dir.path()).await;
9533        w.write_session_start(Some(10), None, Vec::new())
9534            .await
9535            .unwrap();
9536        w.write_task_start("tsk_ran", "ctx_ran", "a2a", event_provenance(), 3, &[], &[])
9537            .await
9538            .unwrap();
9539        w.write_task_end("tsk_ran", "ok", 0, Vec::new(), None, None)
9540            .await
9541            .unwrap();
9542        w.write_task_rejected(
9543            "tsk_1",
9544            "ctx_1",
9545            "detached_shell",
9546            TASK_REJECTED_SESSION_ENDED,
9547            "task rejected: the session ended before this task started",
9548        )
9549        .await
9550        .unwrap();
9551        w.flush().await.unwrap();
9552
9553        let events = read_events(dir.path());
9554        let session = events
9555            .iter()
9556            .find(|e| e["event_type"] == "session_start")
9557            .unwrap();
9558        let rejected: Vec<_> = events
9559            .iter()
9560            .filter(|e| e["event_type"] == "task_rejected")
9561            .collect();
9562        assert_eq!(rejected.len(), 1);
9563        let rejected = rejected[0];
9564        assert_eq!(rejected["parent_id"], session["event_id"], "{rejected}");
9565        assert_eq!(rejected["session_id"], session["session_id"]);
9566        assert_eq!(rejected["task_id"], "tsk_1");
9567        assert_eq!(rejected["context_id"], "ctx_1");
9568        assert_eq!(rejected["source"], "detached_shell");
9569        assert_eq!(rejected["cause"], "session_ended");
9570        assert_eq!(
9571            rejected["reason"],
9572            "task rejected: the session ended before this task started"
9573        );
9574        assert!(rejected["event_id"].is_string());
9575        assert!(rejected["timestamp"].is_u64());
9576        assert_eq!(rejected.as_object().unwrap().len(), 11, "{rejected}");
9577    }
9578
9579    /// `task_failed` hangs off the task it failed, and a reason past the cap is cut on a character
9580    /// boundary. Both optional fields are absent, not null, when there is nothing to say.
9581    #[tokio::test]
9582    async fn task_failed_names_the_task_turn_cause_and_capped_reason() {
9583        let dir = tempfile::tempdir().unwrap();
9584        let mut w = make_writer(dir.path()).await;
9585        w.write_session_start(Some(10), None, Vec::new())
9586            .await
9587            .unwrap();
9588        assert_eq!(w.last_task_failure(), None);
9589        w.write_task_failed(None, TASK_FAILED_RUNTIME_ERROR, "before any task")
9590            .await
9591            .unwrap();
9592        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9593            .await
9594            .unwrap();
9595        let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
9596        w.write_task_failed(Some(2), TASK_FAILED_DRIVER_ERROR, &long)
9597            .await
9598            .unwrap();
9599        w.write_task_end("tsk_1", "failed", 0, Vec::new(), None, None)
9600            .await
9601            .unwrap();
9602        w.flush().await.unwrap();
9603
9604        assert_eq!(w.task_failures_written(), 2);
9605        let events = read_events(dir.path());
9606        let failed: Vec<_> = events
9607            .iter()
9608            .filter(|e| e["event_type"] == "task_failed")
9609            .collect();
9610        assert_eq!(failed.len(), 2);
9611
9612        assert!(failed[0].get("task_id").is_none(), "{}", failed[0]);
9613        assert!(failed[0].get("turn").is_none(), "{}", failed[0]);
9614        assert_eq!(failed[0]["cause"], "runtime_error");
9615        assert_eq!(failed[0]["reason"], "before any task");
9616
9617        let start = events
9618            .iter()
9619            .find(|e| e["event_type"] == "task_start")
9620            .unwrap();
9621        assert_eq!(failed[1]["task_id"], "tsk_1");
9622        assert_eq!(failed[1]["turn"], 2);
9623        assert_eq!(failed[1]["cause"], "driver_error");
9624        assert_eq!(failed[1]["parent_id"], start["event_id"]);
9625        assert_eq!(failed[1]["session_id"], start["session_id"]);
9626        assert!(failed[1]["event_id"].is_string());
9627        assert!(failed[1]["timestamp"].is_u64());
9628        let reason = failed[1]["reason"].as_str().unwrap();
9629        assert!(reason.len() <= MAX_TASK_FAILURE_REASON_BYTES);
9630        assert!(reason.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
9631        assert_eq!(w.last_task_failure(), Some(reason));
9632    }
9633
9634    /// Turn-level events carry the enclosing task's id, and `null` once the task has ended —
9635    /// `task_id` is a record of scope, not a value the writer holds on to.
9636    #[tokio::test]
9637    async fn turn_level_events_carry_the_active_task_id() {
9638        let dir = tempfile::tempdir().unwrap();
9639        let mut w = make_writer(dir.path()).await;
9640        w.write_session_start(Some(10), None, Vec::new())
9641            .await
9642            .unwrap();
9643        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9644            .await
9645            .unwrap();
9646        w.write_inference(
9647            0,
9648            Some(10),
9649            Some(5),
9650            "tool_call".to_string(),
9651            Some("tool_call"),
9652            None,
9653            None,
9654            None,
9655            Vec::new(),
9656            None,
9657            None,
9658        )
9659        .await
9660        .unwrap();
9661        w.write_skill_call(
9662            0,
9663            "house-style".to_string(),
9664            &LockOrigin::Operator,
9665            12,
9666            1,
9667            "ok".to_string(),
9668        )
9669        .await
9670        .unwrap();
9671        w.write_compaction(0, 100, OccupancyBasis::Estimate, 40)
9672            .await
9673            .unwrap();
9674        w.write_task_end("tsk_1", "ok", 0, Vec::new(), None, None)
9675            .await
9676            .unwrap();
9677        w.write_inference(
9678            1,
9679            Some(1),
9680            Some(1),
9681            "end_turn".to_string(),
9682            Some("end_turn"),
9683            None,
9684            None,
9685            None,
9686            Vec::new(),
9687            None,
9688            None,
9689        )
9690        .await
9691        .unwrap();
9692        w.flush().await.unwrap();
9693
9694        let events = read_events(dir.path());
9695        for event in events
9696            .iter()
9697            .filter(|e| {
9698                matches!(
9699                    e["event_type"].as_str(),
9700                    Some("inference" | "skill_call" | "compaction")
9701                )
9702            })
9703            .take(3)
9704        {
9705            assert_eq!(event["task_id"], "tsk_1", "{event}");
9706        }
9707        let after = events.last().unwrap();
9708        assert_eq!(after["event_type"], "inference");
9709        assert!(
9710            after["task_id"].is_null(),
9711            "an inference outside any task carries a null task_id, not the last task's"
9712        );
9713        // Once the task has ended, its events fall back to the session node.
9714        assert_eq!(after["parent_id"], events[0]["event_id"]);
9715    }
9716
9717    /// The provider's id is recorded verbatim; an absent or empty one records as `null`, never
9718    /// as an id the provider never issued.
9719    #[tokio::test]
9720    async fn tool_call_records_the_provider_id_verbatim_and_empty_as_null() {
9721        for (given, expected) in [
9722            (Some("toolu_1".to_string()), Some("toolu_1")),
9723            (Some(String::new()), None),
9724            (None, None),
9725        ] {
9726            let dir = tempfile::tempdir().unwrap();
9727            let mut w = make_writer(dir.path()).await;
9728            w.write_tool_call(
9729                0,
9730                "bash".to_string(),
9731                given.clone(),
9732                serde_json::json!({}),
9733                2,
9734                "",
9735                0,
9736                1,
9737                "ok".to_string(),
9738                None,
9739                None,
9740                None,
9741            )
9742            .await
9743            .unwrap();
9744            w.flush().await.unwrap();
9745            let e = &read_events(dir.path())[0];
9746            match expected {
9747                Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
9748                None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
9749            }
9750        }
9751    }
9752
9753    /// `tool_input_refused` carries the tool, its call id (`null` when absent or empty), the
9754    /// missing names and the reason, and is written under every capture mode alike.
9755    #[tokio::test]
9756    async fn tool_input_refused_records_its_fields_in_every_capture_mode() {
9757        for capture in [
9758            TraceCapture::None,
9759            TraceCapture::Meta,
9760            TraceCapture::Content,
9761        ] {
9762            for (given, expected) in [
9763                (Some("toolu_1"), Some("toolu_1")),
9764                (Some(""), None),
9765                (None, None),
9766            ] {
9767                let dir = tempfile::tempdir().unwrap();
9768                let mut w = make_writer_with_opts(dir.path(), capture).await;
9769                w.write_tool_input_refused(
9770                    2,
9771                    "murmur-tool-editor",
9772                    given,
9773                    &["operation".to_string(), "dest_path".to_string()],
9774                    "murmur-tool-editor: missing required fields",
9775                )
9776                .await
9777                .unwrap();
9778                w.flush().await.unwrap();
9779                let e = &read_events(dir.path())[0];
9780                assert_eq!(e["event_type"], "tool_input_refused", "{capture:?}");
9781                assert!(e["event_id"].is_string());
9782                assert!(e["session_id"].is_string());
9783                assert!(e["timestamp"].is_u64());
9784                assert_eq!(e["turn"], 2);
9785                assert_eq!(e["tool_name"], "murmur-tool-editor");
9786                match expected {
9787                    Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
9788                    None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
9789                }
9790                assert_eq!(e["missing"], serde_json::json!(["operation", "dest_path"]));
9791                assert_eq!(e["reason"], "murmur-tool-editor: missing required fields");
9792            }
9793        }
9794    }
9795
9796    /// `tools_refreshed` carries the turn, the task, the trigger and three sorted name lists,
9797    /// hangs off the task node rather than the previous turn, and `session_start` records the
9798    /// declared trigger — `null` under `transport: process`.
9799    #[tokio::test]
9800    async fn tools_refreshed_event_records_trigger_names_and_task_parent() {
9801        let dir = tempfile::tempdir().unwrap();
9802        let mut w = make_writer(dir.path()).await;
9803        w.set_tool_refresh(Some("immediate"));
9804        w.write_session_start(Some(10), None, Vec::new())
9805            .await
9806            .unwrap();
9807        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9808            .await
9809            .unwrap();
9810        w.write_inference(
9811            0,
9812            Some(10),
9813            Some(5),
9814            "tool_call".to_string(),
9815            Some("tool_call"),
9816            None,
9817            None,
9818            None,
9819            Vec::new(),
9820            None,
9821            None,
9822        )
9823        .await
9824        .unwrap();
9825        w.write_tools_refreshed(
9826            1,
9827            "immediate",
9828            &["aaa-late-skill".to_string()],
9829            &[],
9830            &[
9831                "aaa-late-skill".to_string(),
9832                "zzz-existing-tool".to_string(),
9833            ],
9834        )
9835        .await
9836        .unwrap();
9837        w.flush().await.unwrap();
9838
9839        let events = read_events(dir.path());
9840        assert_eq!(events[0]["tool_refresh"], "immediate");
9841        let task = events
9842            .iter()
9843            .find(|e| e["event_type"] == "task_start")
9844            .unwrap();
9845        let refreshed: Vec<&Value> = events
9846            .iter()
9847            .filter(|e| e["event_type"] == "tools_refreshed")
9848            .collect();
9849        assert_eq!(refreshed.len(), 1);
9850        let r = refreshed[0];
9851        assert!(r["event_id"].as_str().unwrap().starts_with("evt_"));
9852        assert_eq!(r["parent_id"], task["event_id"]);
9853        assert_eq!(r["session_id"], "test-session-id");
9854        assert!(r["timestamp"].as_u64().is_some());
9855        assert_eq!(r["turn"], 1);
9856        assert_eq!(r["task_id"], "tsk_1");
9857        assert_eq!(r["trigger"], "immediate");
9858        assert_eq!(r["added"], serde_json::json!(["aaa-late-skill"]));
9859        assert_eq!(r["removed"], serde_json::json!([]));
9860        assert_eq!(
9861            r["tools"],
9862            serde_json::json!(["aaa-late-skill", "zzz-existing-tool"])
9863        );
9864
9865        let process_dir = tempfile::tempdir().unwrap();
9866        let mut process = make_writer(process_dir.path()).await;
9867        process.set_tool_refresh(None);
9868        process
9869            .write_session_start(Some(10), None, Vec::new())
9870            .await
9871            .unwrap();
9872        process.flush().await.unwrap();
9873        let start = &read_events(process_dir.path())[0];
9874        assert!(start.as_object().unwrap().contains_key("tool_refresh"));
9875        assert!(start["tool_refresh"].is_null());
9876    }
9877
9878    /// A declined compaction is a full record of the decline, not a bare marker: it names the
9879    /// turn, the task, the occupancy the session went on running over, and the reason.
9880    #[tokio::test]
9881    async fn compaction_declined_records_turn_task_tokens_and_reason() {
9882        let dir = tempfile::tempdir().unwrap();
9883        let mut w = make_writer(dir.path()).await;
9884        w.write_session_start(Some(10), None, Vec::new())
9885            .await
9886            .unwrap();
9887        w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9888            .await
9889            .unwrap();
9890        w.write_inference(
9891            2,
9892            Some(10),
9893            Some(5),
9894            "tool_call".to_string(),
9895            Some("tool_call"),
9896            None,
9897            None,
9898            None,
9899            Vec::new(),
9900            None,
9901            None,
9902        )
9903        .await
9904        .unwrap();
9905        w.write_compaction_declined(
9906            2,
9907            4321,
9908            OccupancyBasis::Actual,
9909            COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL,
9910        )
9911        .await
9912        .unwrap();
9913        w.flush().await.unwrap();
9914
9915        let events = read_events(dir.path());
9916        let declined: Vec<&Value> = events
9917            .iter()
9918            .filter(|e| e["event_type"] == "compaction_declined")
9919            .collect();
9920        assert_eq!(declined.len(), 1);
9921        let d = declined[0];
9922        assert_eq!(d["reason"], "unresolved_tool_call");
9923        assert_eq!(d["turn"], 2);
9924        assert_eq!(d["task_id"], "tsk_1");
9925        assert_eq!(d["tokens"], 4321);
9926        assert_eq!(d["tokens_source"], "actual");
9927        assert!(d["event_id"].as_str().unwrap().starts_with("evt_"));
9928        assert_eq!(
9929            d["parent_id"],
9930            events
9931                .iter()
9932                .find(|e| e["event_type"] == "inference")
9933                .unwrap()["event_id"]
9934        );
9935    }
9936
9937    /// `evt_` ids sort by mint time and carry their own millisecond timestamp, the same
9938    /// property `mur trace list --since` reads off a `ses_` id.
9939    #[tokio::test]
9940    async fn event_ids_sort_by_mint_time() {
9941        let dir = tempfile::tempdir().unwrap();
9942        let mut w = make_writer(dir.path()).await;
9943        for turn in 0..5 {
9944            w.write_inference(
9945                turn,
9946                Some(1),
9947                Some(1),
9948                "end_turn".to_string(),
9949                Some("end_turn"),
9950                None,
9951                None,
9952                None,
9953                Vec::new(),
9954                None,
9955                None,
9956            )
9957            .await
9958            .unwrap();
9959        }
9960        w.flush().await.unwrap();
9961
9962        let ids: Vec<String> = read_events(dir.path())
9963            .iter()
9964            .map(|e| e["event_id"].as_str().unwrap().to_string())
9965            .collect();
9966        let mut sorted = ids.clone();
9967        sorted.sort();
9968        assert_eq!(ids, sorted, "evt_ ids must sort into mint order");
9969    }
9970
9971    // ── Wire capture: hashes, blobs and the capture gate ─────────────────────
9972
9973    /// A payload in the shape `build_driver_payload` produces, with `n` messages.
9974    fn wire_payload(system: &str, messages: usize) -> Value {
9975        let messages: Vec<Value> = (0..messages)
9976            .map(|i| serde_json::json!({"role": "user", "content": format!("m{i}")}))
9977            .collect();
9978        serde_json::json!({
9979            "model": "claude-test",
9980            "max_tokens": 1024,
9981            "messages": messages,
9982            "tools": [{"name": "bash"}],
9983            "params": {},
9984            "system": system,
9985        })
9986    }
9987
9988    async fn write_turn(w: &mut TraceWriter, turn: u32, payload: &Value, response: &str) {
9989        let wire = WireCapture::from_driver_payload(payload, response);
9990        w.write_inference(
9991            turn,
9992            Some(10),
9993            Some(5),
9994            "end_turn".to_string(),
9995            Some("end_turn"),
9996            None,
9997            None,
9998            None,
9999            vec!["msg_0000000000000000000000000000000a".to_string()],
10000            Some(&wire),
10001            None,
10002        )
10003        .await
10004        .unwrap();
10005    }
10006
10007    fn blob_path(dir: &std::path::Path, sha: &str) -> std::path::PathBuf {
10008        dir.join(BLOB_DIR_NAME).join(sha)
10009    }
10010
10011    /// Every hash an `inference` event names under `content` is the filename of a real file whose
10012    /// own digest is that filename — the whole point of the store.
10013    #[tokio::test]
10014    async fn content_capture_names_blobs_that_exist_and_rehash_to_their_names() {
10015        let dir = tempfile::tempdir().unwrap();
10016        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10017        let payload = wire_payload("You are a capsule.", 3);
10018        write_turn(&mut w, 0, &payload, r#"{"stop_reason":"end_turn"}"#).await;
10019        w.flush().await.unwrap();
10020
10021        let e = &read_events(dir.path())[0];
10022        let mut named: Vec<String> = ["system_sha", "tools_sha", "response_sha"]
10023            .iter()
10024            .map(|key| {
10025                e[*key]
10026                    .as_str()
10027                    .unwrap_or_else(|| panic!("{key} missing from {e}"))
10028                    .to_string()
10029            })
10030            .collect();
10031        let shas = e["message_shas"].as_array().expect("message_shas");
10032        assert_eq!(shas.len(), 3);
10033        named.extend(shas.iter().map(|s| s.as_str().unwrap().to_string()));
10034
10035        for sha in &named {
10036            let path = blob_path(dir.path(), sha);
10037            let bytes =
10038                std::fs::read(&path).unwrap_or_else(|err| panic!("blob {sha} must exist: {err}"));
10039            assert_eq!(murmur_artifact::sha256_hex(&bytes), *sha);
10040            // A bare sha256 names content; `evt_`/`msg_`/`ses_` name entities. Neither a prefix
10041            // nor an extension may creep onto a blob name.
10042            assert_eq!(sha.len(), 64, "{sha}");
10043            assert!(
10044                sha.chars()
10045                    .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
10046                "{sha}"
10047            );
10048        }
10049    }
10050
10051    /// The hashed system body is the payload's `system` string, as text — so the blob reads as a
10052    /// prompt rather than as a JSON-quoted one.
10053    #[tokio::test]
10054    async fn the_system_blob_holds_the_prompt_as_readable_text() {
10055        let dir = tempfile::tempdir().unwrap();
10056        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10057        let payload = wire_payload("[Capsule] you are cap.\nBe terse.", 1);
10058        write_turn(&mut w, 0, &payload, "{}").await;
10059        w.flush().await.unwrap();
10060
10061        let sha = read_events(dir.path())[0]["system_sha"]
10062            .as_str()
10063            .unwrap()
10064            .to_string();
10065        assert_eq!(
10066            std::fs::read_to_string(blob_path(dir.path(), &sha)).unwrap(),
10067            "[Capsule] you are cap.\nBe terse."
10068        );
10069    }
10070
10071    /// `meta` is the default and hashes exactly what `content` hashes; the difference is only
10072    /// whether the bodies are on disk.
10073    #[tokio::test]
10074    async fn meta_writes_the_same_hashes_and_no_bodies() {
10075        let payload = wire_payload("You are a capsule.", 2);
10076        let response = r#"{"stop_reason":"end_turn"}"#;
10077
10078        let meta = tempfile::tempdir().unwrap();
10079        let mut w = make_writer_with_opts(meta.path(), TraceCapture::Meta).await;
10080        write_turn(&mut w, 0, &payload, response).await;
10081        w.flush().await.unwrap();
10082
10083        let content = tempfile::tempdir().unwrap();
10084        let mut w = make_writer_with_opts(content.path(), TraceCapture::Content).await;
10085        write_turn(&mut w, 0, &payload, response).await;
10086        w.flush().await.unwrap();
10087
10088        let meta_event = read_events(meta.path()).remove(0);
10089        let content_event = read_events(content.path()).remove(0);
10090        for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
10091            assert_eq!(meta_event[key], content_event[key], "{key}");
10092        }
10093        assert!(
10094            !meta.path().join(BLOB_DIR_NAME).exists(),
10095            "meta must not create the blob directory"
10096        );
10097        assert!(content.path().join(BLOB_DIR_NAME).is_dir());
10098    }
10099
10100    /// The default a writer gets when the manifest declares no `trace:` block.
10101    #[tokio::test]
10102    async fn the_default_capture_mode_is_meta() {
10103        assert_eq!(TraceCapture::default(), TraceCapture::Meta);
10104        let dir = tempfile::tempdir().unwrap();
10105        let mut w = make_writer_with_prompt(dir.path(), TraceCapture::default(), None, false).await;
10106        write_turn(&mut w, 0, &wire_payload("s", 1), "{}").await;
10107        w.flush().await.unwrap();
10108
10109        let e = &read_events(dir.path())[0];
10110        assert!(e.get("system_sha").is_some());
10111        assert!(!dir.path().join(BLOB_DIR_NAME).exists());
10112    }
10113
10114    /// Under `none` not one of the four hash keys is present, and every other field is.
10115    #[tokio::test]
10116    async fn none_omits_every_hash_and_leaves_the_rest_of_the_event_intact() {
10117        let payload = wire_payload("You are a capsule.", 2);
10118
10119        let off = tempfile::tempdir().unwrap();
10120        let mut w = make_writer_with_opts(off.path(), TraceCapture::None).await;
10121        write_turn(&mut w, 3, &payload, "{}").await;
10122        w.flush().await.unwrap();
10123
10124        let e = read_events(off.path()).remove(0);
10125        for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
10126            assert!(
10127                e.get(key).is_none(),
10128                "{key} must be absent under none, got {e}"
10129            );
10130        }
10131        assert!(!off.path().join(BLOB_DIR_NAME).exists());
10132        for key in [
10133            "event_type",
10134            "event_id",
10135            "parent_id",
10136            "session_id",
10137            "timestamp",
10138            "turn",
10139            "task_id",
10140            "input_tokens",
10141            "output_tokens",
10142            "decision",
10143            "tool_name",
10144            "message_ids",
10145        ] {
10146            assert!(
10147                e.get(key).is_some(),
10148                "{key} must survive under none, got {e}"
10149            );
10150        }
10151        assert_eq!(e["turn"], 3);
10152        assert_eq!(e["message_ids"].as_array().unwrap().len(), 1);
10153    }
10154
10155    /// A prompt that does not change across ten turns is one blob, written once and never
10156    /// rewritten — the file's bytes are replaced by hand after the first turn and must survive.
10157    #[tokio::test]
10158    async fn an_unchanged_system_prompt_is_stored_once() {
10159        let dir = tempfile::tempdir().unwrap();
10160        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10161        let payload = wire_payload("You are a capsule.", 1);
10162        write_turn(&mut w, 0, &payload, "{}").await;
10163
10164        let sha = {
10165            w.flush().await.unwrap();
10166            read_events(dir.path())[0]["system_sha"]
10167                .as_str()
10168                .unwrap()
10169                .to_string()
10170        };
10171        let path = blob_path(dir.path(), &sha);
10172        std::fs::write(&path, b"sentinel").unwrap();
10173
10174        for turn in 1..10 {
10175            write_turn(&mut w, turn, &payload, "{}").await;
10176        }
10177        w.flush().await.unwrap();
10178
10179        let events = read_events(dir.path());
10180        assert_eq!(events.len(), 10);
10181        for e in &events {
10182            assert_eq!(e["system_sha"], sha.as_str());
10183        }
10184        assert_eq!(
10185            std::fs::read(&path).unwrap(),
10186            b"sentinel",
10187            "an existing blob is never rewritten"
10188        );
10189        let matching = std::fs::read_dir(dir.path().join(BLOB_DIR_NAME))
10190            .unwrap()
10191            .filter(|entry| entry.as_ref().unwrap().file_name() == sha.as_str())
10192            .count();
10193        assert_eq!(matching, 1, "exactly one file bears that name");
10194    }
10195
10196    /// A record written for a completion the runtime parsed no driver response for names no stop
10197    /// reason: the key is absent, never an empty string. Absence means "no driver response was
10198    /// parsed here", so a reader can tell it apart from a driver that reported nothing — which is
10199    /// recorded as `""`.
10200    #[tokio::test]
10201    async fn stop_reason_is_absent_when_no_driver_response_was_parsed() {
10202        let dir = tempfile::tempdir().unwrap();
10203        let mut w = make_writer(dir.path()).await;
10204        w.write_inference(
10205            0,
10206            Some(10),
10207            Some(5),
10208            "end_turn".to_string(),
10209            None,
10210            None,
10211            None,
10212            None,
10213            Vec::new(),
10214            None,
10215            None,
10216        )
10217        .await
10218        .unwrap();
10219        w.flush().await.unwrap();
10220
10221        let event = read_events(dir.path()).remove(0);
10222        assert!(
10223            event.get("stop_reason").is_none(),
10224            "stop_reason must be absent, got {event}"
10225        );
10226    }
10227
10228    /// A hook's `run-inference` sends a payload the runtime never built, so its record names no
10229    /// hashes — absent from the JSON, not empty strings.
10230    #[tokio::test]
10231    async fn a_hook_origin_record_carries_no_hashes() {
10232        let dir = tempfile::tempdir().unwrap();
10233        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10234        let origin = InferenceOrigin {
10235            source: "hook:gatekeeper".to_string(),
10236            model: "claude-test".to_string(),
10237        };
10238        w.write_inference(
10239            0,
10240            Some(1),
10241            Some(1),
10242            "end_turn".to_string(),
10243            // A hook's `run-inference` saw no provider stop reason, so it records none.
10244            None,
10245            None,
10246            Some(&origin),
10247            None,
10248            Vec::new(),
10249            None,
10250            None,
10251        )
10252        .await
10253        .unwrap();
10254        w.flush().await.unwrap();
10255
10256        let e = read_events(dir.path()).remove(0);
10257        assert_eq!(e["origin"], "hook:gatekeeper");
10258        for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
10259            assert!(e.get(key).is_none(), "{key} must be absent, got {e}");
10260        }
10261        assert!(!dir.path().join(BLOB_DIR_NAME).exists());
10262    }
10263
10264    /// Two runs sharing a prefix agree on that prefix's `message_shas` and disagree from the
10265    /// changed message onwards — the divergence index is the first unequal position.
10266    #[tokio::test]
10267    async fn message_shas_locate_the_index_two_runs_diverge_at() {
10268        async fn shas_for(payload: &Value) -> Vec<String> {
10269            let dir = tempfile::tempdir().unwrap();
10270            let mut w = make_writer_with_opts(dir.path(), TraceCapture::Meta).await;
10271            write_turn(&mut w, 0, payload, "{}").await;
10272            w.flush().await.unwrap();
10273            read_events(dir.path())[0]["message_shas"]
10274                .as_array()
10275                .unwrap()
10276                .iter()
10277                .map(|s| s.as_str().unwrap().to_string())
10278                .collect()
10279        }
10280
10281        let first = wire_payload("You are a capsule.", 4);
10282        let mut second = first.clone();
10283        second["messages"][2]["content"] = serde_json::json!("diverged");
10284
10285        let a = shas_for(&first).await;
10286        let b = shas_for(&second).await;
10287        assert_eq!(a.len(), 4);
10288        let divergence = a.iter().zip(&b).position(|(x, y)| x != y);
10289        assert_eq!(divergence, Some(2));
10290        assert_eq!(a[3], b[3], "only the changed message changes its own sha");
10291    }
10292
10293    /// A message repeated inside one request hashes to one name and one file. Ids never repeat;
10294    /// shas repeat exactly when content does, and that is what makes them comparable.
10295    #[tokio::test]
10296    async fn identical_messages_share_one_sha_and_one_blob() {
10297        let dir = tempfile::tempdir().unwrap();
10298        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10299        let payload = serde_json::json!({
10300            "messages": [{"role": "user", "content": "same"}, {"role": "user", "content": "same"}],
10301            "tools": [],
10302            "system": "s",
10303        });
10304        write_turn(&mut w, 0, &payload, "{}").await;
10305        w.flush().await.unwrap();
10306
10307        let shas = read_events(dir.path())[0]["message_shas"]
10308            .as_array()
10309            .unwrap()
10310            .iter()
10311            .map(|s| s.as_str().unwrap().to_string())
10312            .collect::<Vec<_>>();
10313        assert_eq!(shas[0], shas[1]);
10314        assert!(blob_path(dir.path(), &shas[0]).is_file());
10315    }
10316
10317    /// Blobs are the bytes as sent, so a peer handle a tool call would have had redacted out of
10318    /// its summary stays in the body a `content` capture stores.
10319    #[tokio::test]
10320    async fn a_message_blob_is_the_body_verbatim() {
10321        let dir = tempfile::tempdir().unwrap();
10322        let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10323        let message = serde_json::json!({"role": "user", "content": "hello"});
10324        let payload = serde_json::json!({
10325            "messages": [message.clone()],
10326            "tools": [],
10327            "system": "s",
10328        });
10329        write_turn(&mut w, 0, &payload, "{}").await;
10330        w.flush().await.unwrap();
10331
10332        let sha = read_events(dir.path())[0]["message_shas"][0]
10333            .as_str()
10334            .unwrap()
10335            .to_string();
10336        assert_eq!(
10337            std::fs::read(blob_path(dir.path(), &sha)).unwrap(),
10338            serde_json::to_vec(&message).unwrap()
10339        );
10340    }
10341
10342    /// A delegation record a plan's `capsule` step wrote and one `delegate-task` wrote are the
10343    /// same record.
10344    ///
10345    /// Identical key sets and identical values on everything but the two fields that are
10346    /// per-line by construction — the event id and the clock. That is what makes a trace
10347    /// unable to say which surface launched a child, and it holds because both appenders
10348    /// serialize the same struct rather than because this test watches them.
10349    #[tokio::test]
10350    async fn a_plan_written_delegation_record_matches_a_delegate_task_one() {
10351        let plan_dir = tempfile::tempdir().unwrap();
10352        let tool_dir = tempfile::tempdir().unwrap();
10353        let session = "ses_0000000000004000800000000000plan".to_string();
10354        let node = "evt_0000000000004000800000000000node".to_string();
10355
10356        let plan = PlanTraceAppender::open(
10357            &crate::workdir_fs::WorkdirRoot::open(plan_dir.path()).unwrap(),
10358            session.clone(),
10359            node.clone(),
10360        )
10361        .unwrap();
10362        let tool = ResourceTraceAppender::open(
10363            &crate::workdir_fs::WorkdirRoot::open(tool_dir.path()).unwrap(),
10364            session.clone(),
10365            node.clone(),
10366        )
10367        .await
10368        .unwrap();
10369
10370        let snapshot = crate::workdir_snapshot::WorkdirSnapshot {
10371            source: "/home/you/project".to_string(),
10372            files: 2,
10373            dirs: 0,
10374            symlinks: 0,
10375            repointed_symlinks: 0,
10376            bytes: 12,
10377            skipped_count: 0,
10378            skipped: Vec::new(),
10379        };
10380        plan.write_delegation_start(
10381            "dlg_abc123",
10382            "worker",
10383            "0.1.0",
10384            "ses_child",
10385            ".murmur/c",
10386            "/r/ses_child",
10387            Some(&snapshot),
10388        );
10389        tool.write_delegation_start(
10390            "dlg_abc123",
10391            "worker",
10392            "0.1.0",
10393            "ses_child",
10394            ".murmur/c",
10395            "/r/ses_child",
10396            Some(&snapshot),
10397        )
10398        .await;
10399        plan.write_delegation(
10400            "worker",
10401            "0.1.0",
10402            Some("dlg_abc123".to_string()),
10403            Some("ses_child".to_string()),
10404            42,
10405            "completed",
10406            None,
10407        );
10408        tool.write_delegation(
10409            "worker",
10410            "0.1.0",
10411            Some("dlg_abc123".to_string()),
10412            Some("ses_child".to_string()),
10413            42,
10414            "completed",
10415            None,
10416        )
10417        .await;
10418
10419        let read = |dir: &Path| -> Vec<serde_json::Map<String, Value>> {
10420            std::fs::read_to_string(dir.join("trace.jsonl"))
10421                .unwrap()
10422                .lines()
10423                .map(|line| serde_json::from_str::<serde_json::Map<_, _>>(line).unwrap())
10424                .collect()
10425        };
10426        let from_plan = read(plan_dir.path());
10427        let from_tool = read(tool_dir.path());
10428        assert_eq!(from_plan.len(), 2);
10429        assert_eq!(from_tool.len(), 2);
10430        assert_eq!(
10431            from_plan[0]["workdir_snapshot"],
10432            serde_json::to_value(&snapshot).unwrap()
10433        );
10434
10435        for (planned, called, event_type) in [
10436            (&from_plan[0], &from_tool[0], "delegation_start"),
10437            (&from_plan[1], &from_tool[1], "delegation"),
10438        ] {
10439            assert_eq!(planned["event_type"], event_type);
10440            assert_eq!(called["event_type"], event_type);
10441            assert_eq!(
10442                planned.keys().collect::<Vec<_>>(),
10443                called.keys().collect::<Vec<_>>(),
10444                "{planned:?} vs {called:?}"
10445            );
10446            assert_eq!(planned["parent_id"], node.as_str());
10447            assert_eq!(called["parent_id"], node.as_str());
10448            for (key, value) in planned {
10449                // `prev_hash` names the line before, which differs between the two traces.
10450                if key == "event_id" || key == "timestamp" || key == "prev_hash" {
10451                    continue;
10452                }
10453                assert_eq!(value, &called[key], "{key}: {planned:?} vs {called:?}");
10454            }
10455            for key in ["plan_id", "step_id", "surface", "kind"] {
10456                assert!(!planned.contains_key(key), "{key} is on {planned:?}");
10457                assert!(!called.contains_key(key), "{key} is on {called:?}");
10458            }
10459        }
10460    }
10461
10462    /// `spawner_ended` carries the lineage `session_start` does, on the same terms: both keys
10463    /// present for a delegated child, both absent — not null — for a session launched with a
10464    /// lifeline but no spawner handle.
10465    #[tokio::test]
10466    async fn spawner_ended_names_the_lineage_or_omits_it() {
10467        let dir = tempfile::tempdir().unwrap();
10468        let session = "ses_0000000000004000800000000child".to_string();
10469        let node = "evt_0000000000004000800000000000node".to_string();
10470        let appender = ResourceTraceAppender::open(
10471            &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
10472            session.clone(),
10473            node.clone(),
10474        )
10475        .await
10476        .unwrap();
10477
10478        appender
10479            .write_spawner_ended(Some("ses_parent"), Some("dlg_abc123"))
10480            .await;
10481        appender.write_spawner_ended(None, None).await;
10482
10483        let lines: Vec<serde_json::Map<String, Value>> =
10484            std::fs::read_to_string(dir.path().join("trace.jsonl"))
10485                .unwrap()
10486                .lines()
10487                .map(|line| serde_json::from_str(line).unwrap())
10488                .collect();
10489        assert_eq!(lines.len(), 2);
10490        let named = &lines[0];
10491        assert_eq!(named["event_type"], "spawner_ended");
10492        assert_eq!(named["parent_id"], node.as_str());
10493        assert_eq!(named["session_id"], session.as_str());
10494        assert_eq!(named["spawned_by"], "ses_parent");
10495        assert_eq!(named["delegation_id"], "dlg_abc123");
10496        assert_eq!(
10497            named.keys().collect::<Vec<_>>(),
10498            [
10499                "delegation_id",
10500                "event_id",
10501                "event_type",
10502                "parent_id",
10503                "prev_hash",
10504                "session_id",
10505                "spawned_by",
10506                "timestamp"
10507            ]
10508        );
10509        let bare = &lines[1];
10510        assert_eq!(bare["event_type"], "spawner_ended");
10511        assert!(!bare.contains_key("spawned_by"), "{bare:?}");
10512        assert!(!bare.contains_key("delegation_id"), "{bare:?}");
10513    }
10514}