Skip to main content

pointlock_ir/
run_log.rs

1//! The RunLog event vocabulary: the append-only single source of truth of a
2//! run (spine §6.1, closed 17-event union).
3//!
4//! Definition home adjudicated to `pointlock-ir` (type truth source, R12);
5//! pending spine batch incorporation. Payload fields not pinned verbatim by
6//! the spine carry a minimal reasonable shape and are marked pending
7//! incorporation in their doc comments.
8//!
9//! R13 additions: `runStarted`/`runResumed` carry the segment's
10//! `supervisePolicy` (explicitly `null` when unsupervised — per-segment,
11//! never inherited), and `humanRequested`/`humanResponded` carry the
12//! `purpose` discriminator.
13//!
14//! M1 incorporation (spine §6.1, StepRecord event carriers): `stepEntered`
15//! carries `{ stepId, effectHash, judgeHash, resolvedInputs }` and is
16//! appended after the ready-phase input snapshot is frozen, before any
17//! preflight/`actionIntent`; `stepExited` carries `{ state, output? }`
18//! (`output` present when the output projection completed). The checkpoint
19//! fold harvests `StepRecord`'s hash/input/output fields from these events
20//! — no placeholders remain.
21
22use schemars::JsonSchema;
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25
26use crate::primitives::{ActionName, Hash, JsonSchemaDocument, StepId};
27
28/// Deserializes an optional output so that a JSON `null` on the wire
29/// becomes `Some(Value::Null)`; only a missing field is `None` (the field
30/// must also carry `#[serde(default)]`). serde's stock `Option<Value>`
31/// reads `null` as `None`, which would make a step's null output vanish
32/// on refold.
33pub(crate) fn some_even_if_null<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
34where
35    D: serde::Deserializer<'de>,
36{
37    Value::deserialize(deserializer).map(Some)
38}
39use crate::record::{
40    AlignmentReport, AssertionOutcomeRecord, CallFrame, EventCursor, ObservationRecord,
41    ProviderStateSummary,
42};
43use crate::run_path::RunPath;
44use crate::runtime::{ActionOutcome, EvidenceGap, EvidenceRef, Verdict};
45use crate::vocab::{ActChannel, HandlerHook, HumanMode, HumanPurpose, StepState, SupervisePolicy};
46
47/// The envelope of one RunLog event (07 §3.3: `seq` is allocated inside the
48/// appending transaction and is monotonically increasing per run).
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
50#[serde(rename_all = "camelCase", deny_unknown_fields)]
51pub struct RunLogEvent {
52    /// The run this event belongs to.
53    pub run_id: String,
54    /// One-based, per-run monotonic sequence — the authority on order.
55    pub seq: u64,
56    /// Wall-clock timestamp (ms since epoch); informational only.
57    pub at_ms: u64,
58    /// The run path the event is anchored to.
59    pub run_path: RunPath,
60    /// The typed payload.
61    pub payload: RunLogPayload,
62}
63
64/// The closed 17-variant payload union (spine §6.1/A.4).
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
66#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
67pub enum RunLogPayload {
68    /// A run segment started.
69    #[serde(rename_all = "camelCase")]
70    RunStarted {
71        /// Content hash of the executing IR.
72        ir_hash: Hash,
73        /// Digest of the bound capability lockfile.
74        lockfile_digest: Hash,
75        /// The run's input parameters.
76        params_snapshot: Value,
77        /// The segment's supervision policy; explicitly `null` when
78        /// unsupervised (R13 — recorded per segment, never inherited).
79        supervise_policy: Option<SupervisePolicy>,
80    },
81    /// A step was scheduled and its inputs are frozen (appended after the
82    /// ready-phase input snapshot completes, before preflight /
83    /// `actionIntent` — spine §6.1 M1 note).
84    #[serde(rename_all = "camelCase")]
85    StepEntered {
86        /// The entered step.
87        step_id: StepId,
88        /// Effect-domain hash of the step at execution time (alignment
89        /// input; the fold copies it into `StepRecord.effectHash`).
90        effect_hash: Hash,
91        /// Judge-domain hash of the step at execution time (alignment
92        /// input; the fold copies it into `StepRecord.judgeHash`).
93        judge_hash: Hash,
94        /// The ready-phase input snapshot: input expressions evaluated
95        /// once and frozen, never re-evaluated on resume. Explicitly
96        /// `null` for spans whose inputs were never resolved
97        /// (blocked/skipped steps, or a failed argument evaluation).
98        resolved_inputs: Value,
99    },
100    /// Preflight probes were evaluated (pending incorporation of the
101    /// payload shape).
102    #[serde(rename_all = "camelCase")]
103    PreflightProbed {
104        /// One outcome per probe assertion, in declaration order.
105        outcomes: Vec<AssertionOutcomeRecord>,
106    },
107    /// The WAL entry written and fsynced *before* dispatching an action
108    /// (spine §6.2 — the crash-safety anchor).
109    #[serde(rename_all = "camelCase")]
110    ActionIntent {
111        /// The caller-generated action id.
112        call_id: String,
113        /// The evaluated arguments as they will be dispatched.
114        args_snapshot: Value,
115        /// 1-based position in `binding.attempts` (2026-07-18
116        /// incorporation, item ②): the dispatch-identity discriminant —
117        /// the act-chain overlay and the crash-resume chain re-entry
118        /// both key on it. Absent on pre-incorporation ledgers.
119        #[serde(skip_serializing_if = "Option::is_none")]
120        chain_index: Option<u32>,
121        /// The bound attempt's locating channel, verbatim.
122        #[serde(skip_serializing_if = "Option::is_none")]
123        channel: Option<ActChannel>,
124        /// The bound attempt's provider-native action name, verbatim.
125        #[serde(skip_serializing_if = "Option::is_none")]
126        action_name: Option<ActionName>,
127    },
128    /// An action reached its four-way terminal.
129    #[serde(rename_all = "camelCase")]
130    ActionSettled {
131        /// The action id this terminal belongs to.
132        call_id: String,
133        /// The terminal outcome (never folded).
134        outcome: ActionOutcome,
135    },
136    /// An observation was captured and localized.
137    #[serde(rename_all = "camelCase")]
138    ObservationRecorded {
139        /// The localized observation record.
140        observation: ObservationRecord,
141    },
142    /// One assertion finished evaluating along its verify chain.
143    #[serde(rename_all = "camelCase")]
144    AssertionEvaluated {
145        /// The evaluation outcome.
146        outcome: AssertionOutcomeRecord,
147    },
148    /// A verdict was folded and recorded.
149    #[serde(rename_all = "camelCase")]
150    VerdictRecorded {
151        /// The folded verdict.
152        verdict: Verdict,
153        /// Localized settlement/verdict/human-class evidence of THIS
154        /// judgment (item ③, 2026-07-18; observation refs excluded —
155        /// they ride `observationRecorded`). Empty on offline
156        /// re-judgements (nothing is newly localized offline) and on
157        /// pre-incorporation ledgers.
158        #[serde(default, skip_serializing_if = "Vec::is_empty")]
159        localized: Vec<EvidenceRef>,
160        /// Typed localization failures of the same judgment — the
161        /// honest-gap record (principle 4/R4).
162        #[serde(default, skip_serializing_if = "Vec::is_empty")]
163        localization_gaps: Vec<EvidenceGap>,
164        /// The provider's `verdict.record` write-back failure, when the
165        /// remote archival attempt failed (04 §5: the failure never
166        /// changes the local verdict — the RunLog is the sole truth —
167        /// and is annotated in the report as "remote archival failed").
168        #[serde(default, skip_serializing_if = "Option::is_none")]
169        remote_archival_error: Option<String>,
170    },
171    /// A step reached a terminal lifecycle state.
172    #[serde(rename_all = "camelCase")]
173    StepExited {
174        /// The terminal state.
175        state: StepState,
176        /// The projected step output, carried when the output projection
177        /// completed (spine §6.1 M1 note); absent for exits without an
178        /// output (blocked/skipped/aborted, error verdicts). A projected
179        /// JSON `null` IS an output: it rides as `"output": null` and
180        /// refolds as `Some(Null)`, never as absence (I1: the resumed run
181        /// binds what the live run bound).
182        #[serde(
183            default,
184            deserialize_with = "some_even_if_null",
185            skip_serializing_if = "Option::is_none"
186        )]
187        output: Option<Value>,
188        /// The failure-instant session profile (07 §2.2, incorporated
189        /// 2026-07-18): present when a fail/unknown verdict is in force
190        /// at exit; absent otherwise, on aborted follow-up exits (an
191        /// aborted terminal makes no semantic claim), and on ledgers
192        /// recorded before incorporation. One-directional additive
193        /// (R12): pre-field readers reject ledgers carrying it.
194        #[serde(skip_serializing_if = "Option::is_none")]
195        provider_state_summary: Option<ProviderStateSummary>,
196        /// The settlement-evidence manifest of an UNVERIFIED exit
197        /// (item ③ review fix): an assertion-less step records no
198        /// verdict (R4), so its judgment manifest rides the exit
199        /// instead — same merge rule, same honesty. Empty on verdict-
200        /// bearing exits (the manifest rode `verdictRecorded`) and on
201        /// pre-incorporation ledgers.
202        #[serde(default, skip_serializing_if = "Vec::is_empty")]
203        localized: Vec<EvidenceRef>,
204        /// Typed localization failures of the same unverified exit.
205        #[serde(default, skip_serializing_if = "Vec::is_empty")]
206        localization_gaps: Vec<EvidenceGap>,
207    },
208    /// A subflow call frame was pushed.
209    #[serde(rename_all = "camelCase")]
210    CallFramePushed {
211        /// The pushed frame.
212        frame: CallFrame,
213        /// Live-frame RE-ENTRY under a repaired callee, not a new stack
214        /// level (07 §5.2 call down-drill, case (a)): the resume descended
215        /// back into a frame that was still open and the callee's `irHash`
216        /// moved, so `frames` must name the callee actually being executed
217        /// ("frames 中该帧的 irHash 更新为新 callee irHash"). The fold
218        /// updates that frame's `irHash` in place and keeps everything
219        /// else — above all its `inputsSnapshot`, which a new IR never
220        /// re-evaluates (§5.2 corollary / §4.6).
221        ///
222        /// Additive optional field (spine §6.1, the 2026-07-18 payload
223        /// batch): absent on every pre-incorporation ledger, so a refold
224        /// of an old run is byte-identical to what it always was.
225        #[serde(default, skip_serializing_if = "core::ops::Not::not")]
226        rebase: bool,
227    },
228    /// A subflow call frame was popped (pending incorporation of the
229    /// payload shape).
230    #[serde(rename_all = "camelCase")]
231    CallFramePopped {
232        /// The callee's declared outputs, when it completed. Absent (not
233        /// `null`) when there were none; a `null` output refolds as
234        /// `Some(Null)`.
235        #[serde(
236            default,
237            deserialize_with = "some_even_if_null",
238            skip_serializing_if = "Option::is_none"
239        )]
240        outputs: Option<Value>,
241    },
242    /// A handler hook fired.
243    #[serde(rename_all = "camelCase")]
244    HandlerTriggered {
245        /// Which hook fired.
246        hook: HandlerHook,
247        /// One-based trigger count toward `maxTriggers`.
248        trigger: u64,
249        /// The consulted binding's declared disposition head (closed:
250        /// `retry|continue|escalate|abort|repair`, 03 §1.8) — what the
251        /// hook resolved TO, known at emission. Absent on
252        /// pre-incorporation ledgers.
253        #[serde(default, skip_serializing_if = "Option::is_none")]
254        disposition: Option<String>,
255    },
256    /// A human interaction was requested (fsynced *before* notifying any
257    /// channel, spine §6.8/§6.9). M2 incorporation of the 06 §2.1 request
258    /// shape: `mode`, `decisions`, `outputSchema` and the absolute
259    /// `deadlineAtMs` watermark are carried by the event itself, so the
260    /// store arbitration and the lazy timeout settlement need no source
261    /// other than the ledger.
262    #[serde(rename_all = "camelCase")]
263    HumanRequested {
264        /// The request id a response must pair with.
265        request_id: String,
266        /// Step vs supervision gate (R13).
267        purpose: HumanPurpose,
268        /// Interaction mode. Required semantics when `purpose` is `step`;
269        /// absent for supervision gates, which carry no mode (06 §2.1).
270        #[serde(skip_serializing_if = "Option::is_none")]
271        mode: Option<HumanMode>,
272        /// The prompt shown to the human (auto-generated gate description
273        /// for supervision requests).
274        prompt: String,
275        /// The evidence/values presented, materialized once at ready —
276        /// the `resolvedInputs` snapshot discipline (06 §2.3).
277        presents: Value,
278        /// Enumerated options: `confirm` carries exactly two labels
279        /// (position-mapped to pass/fail); `judge` a subset of the
280        /// three-valued vocabulary (06 §2.2).
281        #[serde(skip_serializing_if = "Option::is_none")]
282        decisions: Option<Vec<String>>,
283        /// Input contract for `provideInput` responses; the store
284        /// arbitration validates against it (06 §4.3 rule 3).
285        #[serde(skip_serializing_if = "Option::is_none")]
286        output_schema: Option<JsonSchemaDocument>,
287        /// Absolute response deadline (ms since epoch), converted from
288        /// `timeoutMs` at request creation — the lazy-settlement watermark
289        /// (06 §5.3). Absent for supervision requests (no deadline,
290        /// spine §6.9).
291        #[serde(skip_serializing_if = "Option::is_none")]
292        deadline_at_ms: Option<u64>,
293    },
294    /// A human response was arbitrated and recorded (pending incorporation
295    /// of the payload shape).
296    #[serde(rename_all = "camelCase")]
297    HumanResponded {
298        /// The paired request id.
299        request_id: String,
300        /// Step vs supervision gate (R13).
301        purpose: HumanPurpose,
302        /// The response payload (mode/decision-shaped, arbitrated by the
303        /// store single writer).
304        response: Value,
305        /// Who responded.
306        actor: String,
307    },
308    /// The run segment was suspended.
309    #[serde(rename_all = "camelCase")]
310    RunSuspended {
311        /// Optional human-readable reason. Structured forms the runner
312        /// writes today: `stop requested` (cooperative stop token),
313        /// `awaiting human response (requestId <id>)`, a blocked reason's
314        /// display, and the breakpoint stops `stopped at breakpoint
315        /// --stop-at <canonical path>` / `stopped at breakpoint
316        /// --stop-after <canonical path>` (the matched step instance's
317        /// canonical run path, spine §9). Free text otherwise.
318        reason: Option<String>,
319        /// The suspension-instant session profile (07 §2.2): captured
320        /// whenever a live session exists at the write site; same
321        /// compat posture as on `stepExited`.
322        #[serde(skip_serializing_if = "Option::is_none")]
323        provider_state_summary: Option<ProviderStateSummary>,
324    },
325    /// A run segment resumed from a checkpoint.
326    #[serde(rename_all = "camelCase")]
327    RunResumed {
328        /// The alignment report of this resume (spine §6.7-A).
329        alignment_report: AlignmentReport,
330        /// The segment's supervision policy; explicitly `null` when
331        /// unsupervised (R13 — per segment, never inherited).
332        supervise_policy: Option<SupervisePolicy>,
333        /// The new generation's reseeded cursor (07 §4.5, incorporated
334        /// 2026-07-18): `sessionId` is the lineage extension, taken via
335        /// `currentCursor()` after the reconcile decisions and before
336        /// this append. Absent when the RPC failed at capture — and on
337        /// ledgers recorded before incorporation (one-directional
338        /// additive, R12).
339        #[serde(skip_serializing_if = "Option::is_none")]
340        event_cursor: Option<EventCursor>,
341    },
342    /// The run finished.
343    #[serde(rename_all = "camelCase")]
344    RunFinished {
345        /// The folded flow verdict, when one was produced.
346        verdict: Option<Verdict>,
347        /// The flow verdict's `verdict.record` write-back failure
348        /// (04 §5 — see [`RunLogPayload::VerdictRecorded`]).
349        #[serde(default, skip_serializing_if = "Option::is_none")]
350        remote_archival_error: Option<String>,
351    },
352}
353
354impl RunLogPayload {
355    /// The wire discriminant (`type`) of this payload.
356    pub fn event_type(&self) -> &'static str {
357        match self {
358            RunLogPayload::RunStarted { .. } => "runStarted",
359            RunLogPayload::StepEntered { .. } => "stepEntered",
360            RunLogPayload::PreflightProbed { .. } => "preflightProbed",
361            RunLogPayload::ActionIntent { .. } => "actionIntent",
362            RunLogPayload::ActionSettled { .. } => "actionSettled",
363            RunLogPayload::ObservationRecorded { .. } => "observationRecorded",
364            RunLogPayload::AssertionEvaluated { .. } => "assertionEvaluated",
365            RunLogPayload::VerdictRecorded { .. } => "verdictRecorded",
366            RunLogPayload::StepExited { .. } => "stepExited",
367            RunLogPayload::CallFramePushed { .. } => "callFramePushed",
368            RunLogPayload::CallFramePopped { .. } => "callFramePopped",
369            RunLogPayload::HandlerTriggered { .. } => "handlerTriggered",
370            RunLogPayload::HumanRequested { .. } => "humanRequested",
371            RunLogPayload::HumanResponded { .. } => "humanResponded",
372            RunLogPayload::RunSuspended { .. } => "runSuspended",
373            RunLogPayload::RunResumed { .. } => "runResumed",
374            RunLogPayload::RunFinished { .. } => "runFinished",
375        }
376    }
377
378    /// All seventeen wire discriminants (spine §6.1 closed set).
379    pub const EVENT_TYPES: [&'static str; 17] = [
380        "runStarted",
381        "stepEntered",
382        "preflightProbed",
383        "actionIntent",
384        "actionSettled",
385        "observationRecorded",
386        "assertionEvaluated",
387        "verdictRecorded",
388        "stepExited",
389        "callFramePushed",
390        "callFramePopped",
391        "handlerTriggered",
392        "humanRequested",
393        "humanResponded",
394        "runSuspended",
395        "runResumed",
396        "runFinished",
397    ];
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use serde_json::json;
404
405    fn hash(fill: char) -> Hash {
406        serde_json::from_value(json!(format!("sha256:{}", fill.to_string().repeat(64))))
407            .expect("valid hash literal")
408    }
409
410    #[test]
411    fn run_started_serializes_explicit_null_supervise_policy() {
412        let payload = RunLogPayload::RunStarted {
413            ir_hash: hash('a'),
414            lockfile_digest: hash('b'),
415            params_snapshot: json!({}),
416            supervise_policy: None,
417        };
418        let wire = serde_json::to_value(&payload).expect("serialize");
419        assert_eq!(wire["type"], "runStarted");
420        // R13: explicitly null, not absent — the ledger is per-segment
421        // self-describing about supervision.
422        assert!(
423            wire.as_object()
424                .expect("object")
425                .contains_key("supervisePolicy")
426        );
427        assert_eq!(wire["supervisePolicy"], Value::Null);
428
429        let supervised = RunLogPayload::RunStarted {
430            ir_hash: hash('a'),
431            lockfile_digest: hash('b'),
432            params_snapshot: json!({}),
433            supervise_policy: Some(SupervisePolicy::Mutating),
434        };
435        let wire = serde_json::to_value(&supervised).expect("serialize");
436        assert_eq!(wire["supervisePolicy"], "mutating");
437    }
438
439    #[test]
440    fn step_entered_carries_hashes_and_the_resolved_inputs_snapshot() {
441        let payload = RunLogPayload::StepEntered {
442            step_id: serde_json::from_value(json!("login")).expect("step id"),
443            effect_hash: hash('c'),
444            judge_hash: hash('d'),
445            resolved_inputs: json!({"element": {"identifier": "loginButton"}}),
446        };
447        let wire = serde_json::to_value(&payload).expect("serialize");
448        assert_eq!(wire["type"], "stepEntered");
449        assert_eq!(
450            wire["effectHash"],
451            json!(format!("sha256:{}", "c".repeat(64)))
452        );
453        assert_eq!(
454            wire["judgeHash"],
455            json!(format!("sha256:{}", "d".repeat(64)))
456        );
457        assert_eq!(
458            wire["resolvedInputs"],
459            json!({"element": {"identifier": "loginButton"}})
460        );
461
462        // Blocked/skipped spans never resolve inputs: explicitly null,
463        // never absent (the ledger is self-describing).
464        let unresolved = RunLogPayload::StepEntered {
465            step_id: serde_json::from_value(json!("blocked_step")).expect("step id"),
466            effect_hash: hash('c'),
467            judge_hash: hash('d'),
468            resolved_inputs: Value::Null,
469        };
470        let wire = serde_json::to_value(&unresolved).expect("serialize");
471        assert!(
472            wire.as_object()
473                .expect("object")
474                .contains_key("resolvedInputs")
475        );
476        assert_eq!(wire["resolvedInputs"], Value::Null);
477    }
478
479    #[test]
480    fn step_exited_output_is_present_only_when_projected() {
481        let with_output = RunLogPayload::StepExited {
482            provider_state_summary: None,
483            state: StepState::Judged,
484            output: Some(json!({"ok": true})),
485            localized: Vec::new(),
486            localization_gaps: Vec::new(),
487        };
488        let wire = serde_json::to_value(&with_output).expect("serialize");
489        assert_eq!(wire["type"], "stepExited");
490        assert_eq!(wire["output"], json!({"ok": true}));
491
492        let without = RunLogPayload::StepExited {
493            provider_state_summary: None,
494            state: StepState::Blocked,
495            output: None,
496            localized: Vec::new(),
497            localization_gaps: Vec::new(),
498        };
499        let wire = serde_json::to_value(&without).expect("serialize");
500        assert!(wire.get("output").is_none());
501        let back: RunLogPayload = serde_json::from_value(wire).expect("deserialize");
502        assert_eq!(back, without);
503    }
504
505    #[test]
506    fn null_outputs_survive_the_wire_as_present_not_absent() {
507        // `provideInput` answers and identity projections can yield a JSON
508        // null output; the ledger must keep "output is null" distinct from
509        // "no output" so a refold binds `steps.<id>.output` as the live run did.
510        let exited = RunLogPayload::StepExited {
511            provider_state_summary: None,
512            state: StepState::Judged,
513            output: Some(Value::Null),
514            localized: Vec::new(),
515            localization_gaps: Vec::new(),
516        };
517        let wire = serde_json::to_string(&exited).expect("serialize");
518        assert!(wire.contains("\"output\":null"), "{wire}");
519        let back: RunLogPayload = serde_json::from_str(&wire).expect("deserialize");
520        assert_eq!(back, exited);
521
522        let popped = RunLogPayload::CallFramePopped {
523            outputs: Some(Value::Null),
524        };
525        let wire = serde_json::to_string(&popped).expect("serialize");
526        assert!(wire.contains("\"outputs\":null"), "{wire}");
527        let back: RunLogPayload = serde_json::from_str(&wire).expect("deserialize");
528        assert_eq!(back, popped);
529        let none = RunLogPayload::CallFramePopped { outputs: None };
530        let wire = serde_json::to_value(&none).expect("serialize");
531        assert!(wire.get("outputs").is_none(), "{wire}");
532        let back: RunLogPayload = serde_json::from_value(wire).expect("deserialize");
533        assert_eq!(back, none);
534
535        let record = json!({"output": null});
536        let output: Option<Value> =
537            some_even_if_null(&record["output"]).expect("null deserializes");
538        assert_eq!(output, Some(Value::Null));
539    }
540
541    #[test]
542    fn human_events_carry_the_purpose_discriminator() {
543        // Supervision requests carry no mode/decisions/schema/deadline:
544        // the optionals are absent on the wire, never null.
545        let requested = RunLogPayload::HumanRequested {
546            request_id: "req-1".to_owned(),
547            purpose: HumanPurpose::Supervision,
548            mode: None,
549            prompt: "Approve dispatch".to_owned(),
550            presents: json!([]),
551            decisions: None,
552            output_schema: None,
553            deadline_at_ms: None,
554        };
555        let wire = serde_json::to_value(&requested).expect("serialize");
556        assert_eq!(wire["type"], "humanRequested");
557        assert_eq!(wire["purpose"], "supervision");
558        let object = wire.as_object().expect("object");
559        assert!(!object.contains_key("mode"));
560        assert!(!object.contains_key("decisions"));
561        assert!(!object.contains_key("outputSchema"));
562        assert!(!object.contains_key("deadlineAtMs"));
563        let back: RunLogPayload = serde_json::from_value(wire).expect("deserialize");
564        assert_eq!(back, requested);
565
566        let responded: RunLogPayload = serde_json::from_value(json!({
567            "type": "humanResponded",
568            "requestId": "req-1",
569            "purpose": "supervision",
570            "response": {"decision": "proceed"},
571            "actor": "cli:dengfengwang",
572        }))
573        .expect("deserialize");
574        assert_eq!(responded.event_type(), "humanResponded");
575    }
576
577    #[test]
578    fn human_requested_step_purpose_carries_the_full_request_shape() {
579        let schema = crate::primitives::JsonSchemaDocument::new(json!({
580            "type": "object",
581            "properties": { "code": { "type": "string" } },
582            "required": ["code"]
583        }))
584        .expect("valid schema document");
585        let requested = RunLogPayload::HumanRequested {
586            request_id: "req-2".to_owned(),
587            purpose: HumanPurpose::Step,
588            mode: Some(HumanMode::ProvideInput),
589            prompt: "Enter the code".to_owned(),
590            presents: json!([{"kind": "value", "value": 1}]),
591            decisions: Some(vec!["approve".to_owned(), "reject".to_owned()]),
592            output_schema: Some(schema),
593            deadline_at_ms: Some(1_700_000_600_000),
594        };
595        let wire = serde_json::to_value(&requested).expect("serialize");
596        assert_eq!(wire["mode"], "provideInput");
597        assert_eq!(wire["decisions"], json!(["approve", "reject"]));
598        assert_eq!(wire["outputSchema"]["required"], json!(["code"]));
599        assert_eq!(wire["deadlineAtMs"], json!(1_700_000_600_000_u64));
600        let back: RunLogPayload = serde_json::from_value(wire).expect("deserialize");
601        assert_eq!(back, requested);
602    }
603
604    #[test]
605    fn all_seventeen_discriminants_are_distinct_and_stable() {
606        let mut seen = std::collections::BTreeSet::new();
607        for name in RunLogPayload::EVENT_TYPES {
608            assert!(seen.insert(name), "duplicate event type {name}");
609        }
610        assert_eq!(seen.len(), 17);
611    }
612
613    #[test]
614    fn envelope_round_trips() {
615        let event = RunLogEvent {
616            run_id: "run-1".to_owned(),
617            seq: 7,
618            at_ms: 1_700_000_000_000,
619            run_path: vec![],
620            payload: RunLogPayload::ActionIntent {
621                call_id: "c-1".to_owned(),
622                args_snapshot: json!({"x": 1}),
623                chain_index: None,
624                channel: None,
625                action_name: None,
626            },
627        };
628        let wire = serde_json::to_value(&event).expect("serialize");
629        assert_eq!(wire["payload"]["type"], "actionIntent");
630        assert_eq!(wire["seq"], 7);
631        let back: RunLogEvent = serde_json::from_value(wire).expect("deserialize");
632        assert_eq!(back, event);
633    }
634}