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.
312        reason: Option<String>,
313        /// The suspension-instant session profile (07 §2.2): captured
314        /// whenever a live session exists at the write site; same
315        /// compat posture as on `stepExited`.
316        #[serde(skip_serializing_if = "Option::is_none")]
317        provider_state_summary: Option<ProviderStateSummary>,
318    },
319    /// A run segment resumed from a checkpoint.
320    #[serde(rename_all = "camelCase")]
321    RunResumed {
322        /// The alignment report of this resume (spine §6.7-A).
323        alignment_report: AlignmentReport,
324        /// The segment's supervision policy; explicitly `null` when
325        /// unsupervised (R13 — per segment, never inherited).
326        supervise_policy: Option<SupervisePolicy>,
327        /// The new generation's reseeded cursor (07 §4.5, incorporated
328        /// 2026-07-18): `sessionId` is the lineage extension, taken via
329        /// `currentCursor()` after the reconcile decisions and before
330        /// this append. Absent when the RPC failed at capture — and on
331        /// ledgers recorded before incorporation (one-directional
332        /// additive, R12).
333        #[serde(skip_serializing_if = "Option::is_none")]
334        event_cursor: Option<EventCursor>,
335    },
336    /// The run finished.
337    #[serde(rename_all = "camelCase")]
338    RunFinished {
339        /// The folded flow verdict, when one was produced.
340        verdict: Option<Verdict>,
341        /// The flow verdict's `verdict.record` write-back failure
342        /// (04 §5 — see [`RunLogPayload::VerdictRecorded`]).
343        #[serde(default, skip_serializing_if = "Option::is_none")]
344        remote_archival_error: Option<String>,
345    },
346}
347
348impl RunLogPayload {
349    /// The wire discriminant (`type`) of this payload.
350    pub fn event_type(&self) -> &'static str {
351        match self {
352            RunLogPayload::RunStarted { .. } => "runStarted",
353            RunLogPayload::StepEntered { .. } => "stepEntered",
354            RunLogPayload::PreflightProbed { .. } => "preflightProbed",
355            RunLogPayload::ActionIntent { .. } => "actionIntent",
356            RunLogPayload::ActionSettled { .. } => "actionSettled",
357            RunLogPayload::ObservationRecorded { .. } => "observationRecorded",
358            RunLogPayload::AssertionEvaluated { .. } => "assertionEvaluated",
359            RunLogPayload::VerdictRecorded { .. } => "verdictRecorded",
360            RunLogPayload::StepExited { .. } => "stepExited",
361            RunLogPayload::CallFramePushed { .. } => "callFramePushed",
362            RunLogPayload::CallFramePopped { .. } => "callFramePopped",
363            RunLogPayload::HandlerTriggered { .. } => "handlerTriggered",
364            RunLogPayload::HumanRequested { .. } => "humanRequested",
365            RunLogPayload::HumanResponded { .. } => "humanResponded",
366            RunLogPayload::RunSuspended { .. } => "runSuspended",
367            RunLogPayload::RunResumed { .. } => "runResumed",
368            RunLogPayload::RunFinished { .. } => "runFinished",
369        }
370    }
371
372    /// All seventeen wire discriminants (spine §6.1 closed set).
373    pub const EVENT_TYPES: [&'static str; 17] = [
374        "runStarted",
375        "stepEntered",
376        "preflightProbed",
377        "actionIntent",
378        "actionSettled",
379        "observationRecorded",
380        "assertionEvaluated",
381        "verdictRecorded",
382        "stepExited",
383        "callFramePushed",
384        "callFramePopped",
385        "handlerTriggered",
386        "humanRequested",
387        "humanResponded",
388        "runSuspended",
389        "runResumed",
390        "runFinished",
391    ];
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use serde_json::json;
398
399    fn hash(fill: char) -> Hash {
400        serde_json::from_value(json!(format!("sha256:{}", fill.to_string().repeat(64))))
401            .expect("valid hash literal")
402    }
403
404    #[test]
405    fn run_started_serializes_explicit_null_supervise_policy() {
406        let payload = RunLogPayload::RunStarted {
407            ir_hash: hash('a'),
408            lockfile_digest: hash('b'),
409            params_snapshot: json!({}),
410            supervise_policy: None,
411        };
412        let wire = serde_json::to_value(&payload).expect("serialize");
413        assert_eq!(wire["type"], "runStarted");
414        // R13: explicitly null, not absent — the ledger is per-segment
415        // self-describing about supervision.
416        assert!(
417            wire.as_object()
418                .expect("object")
419                .contains_key("supervisePolicy")
420        );
421        assert_eq!(wire["supervisePolicy"], Value::Null);
422
423        let supervised = RunLogPayload::RunStarted {
424            ir_hash: hash('a'),
425            lockfile_digest: hash('b'),
426            params_snapshot: json!({}),
427            supervise_policy: Some(SupervisePolicy::Mutating),
428        };
429        let wire = serde_json::to_value(&supervised).expect("serialize");
430        assert_eq!(wire["supervisePolicy"], "mutating");
431    }
432
433    #[test]
434    fn step_entered_carries_hashes_and_the_resolved_inputs_snapshot() {
435        let payload = RunLogPayload::StepEntered {
436            step_id: serde_json::from_value(json!("login")).expect("step id"),
437            effect_hash: hash('c'),
438            judge_hash: hash('d'),
439            resolved_inputs: json!({"element": {"identifier": "loginButton"}}),
440        };
441        let wire = serde_json::to_value(&payload).expect("serialize");
442        assert_eq!(wire["type"], "stepEntered");
443        assert_eq!(
444            wire["effectHash"],
445            json!(format!("sha256:{}", "c".repeat(64)))
446        );
447        assert_eq!(
448            wire["judgeHash"],
449            json!(format!("sha256:{}", "d".repeat(64)))
450        );
451        assert_eq!(
452            wire["resolvedInputs"],
453            json!({"element": {"identifier": "loginButton"}})
454        );
455
456        // Blocked/skipped spans never resolve inputs: explicitly null,
457        // never absent (the ledger is self-describing).
458        let unresolved = RunLogPayload::StepEntered {
459            step_id: serde_json::from_value(json!("blocked_step")).expect("step id"),
460            effect_hash: hash('c'),
461            judge_hash: hash('d'),
462            resolved_inputs: Value::Null,
463        };
464        let wire = serde_json::to_value(&unresolved).expect("serialize");
465        assert!(
466            wire.as_object()
467                .expect("object")
468                .contains_key("resolvedInputs")
469        );
470        assert_eq!(wire["resolvedInputs"], Value::Null);
471    }
472
473    #[test]
474    fn step_exited_output_is_present_only_when_projected() {
475        let with_output = RunLogPayload::StepExited {
476            provider_state_summary: None,
477            state: StepState::Judged,
478            output: Some(json!({"ok": true})),
479            localized: Vec::new(),
480            localization_gaps: Vec::new(),
481        };
482        let wire = serde_json::to_value(&with_output).expect("serialize");
483        assert_eq!(wire["type"], "stepExited");
484        assert_eq!(wire["output"], json!({"ok": true}));
485
486        let without = RunLogPayload::StepExited {
487            provider_state_summary: None,
488            state: StepState::Blocked,
489            output: None,
490            localized: Vec::new(),
491            localization_gaps: Vec::new(),
492        };
493        let wire = serde_json::to_value(&without).expect("serialize");
494        assert!(wire.get("output").is_none());
495        let back: RunLogPayload = serde_json::from_value(wire).expect("deserialize");
496        assert_eq!(back, without);
497    }
498
499    #[test]
500    fn null_outputs_survive_the_wire_as_present_not_absent() {
501        // `provideInput` answers and identity projections can yield a JSON
502        // null output; the ledger must keep "output is null" distinct from
503        // "no output" so a refold binds `steps.<id>.output` as the live run did.
504        let exited = RunLogPayload::StepExited {
505            provider_state_summary: None,
506            state: StepState::Judged,
507            output: Some(Value::Null),
508            localized: Vec::new(),
509            localization_gaps: Vec::new(),
510        };
511        let wire = serde_json::to_string(&exited).expect("serialize");
512        assert!(wire.contains("\"output\":null"), "{wire}");
513        let back: RunLogPayload = serde_json::from_str(&wire).expect("deserialize");
514        assert_eq!(back, exited);
515
516        let popped = RunLogPayload::CallFramePopped {
517            outputs: Some(Value::Null),
518        };
519        let wire = serde_json::to_string(&popped).expect("serialize");
520        assert!(wire.contains("\"outputs\":null"), "{wire}");
521        let back: RunLogPayload = serde_json::from_str(&wire).expect("deserialize");
522        assert_eq!(back, popped);
523        let none = RunLogPayload::CallFramePopped { outputs: None };
524        let wire = serde_json::to_value(&none).expect("serialize");
525        assert!(wire.get("outputs").is_none(), "{wire}");
526        let back: RunLogPayload = serde_json::from_value(wire).expect("deserialize");
527        assert_eq!(back, none);
528
529        let record = json!({"output": null});
530        let output: Option<Value> =
531            some_even_if_null(&record["output"]).expect("null deserializes");
532        assert_eq!(output, Some(Value::Null));
533    }
534
535    #[test]
536    fn human_events_carry_the_purpose_discriminator() {
537        // Supervision requests carry no mode/decisions/schema/deadline:
538        // the optionals are absent on the wire, never null.
539        let requested = RunLogPayload::HumanRequested {
540            request_id: "req-1".to_owned(),
541            purpose: HumanPurpose::Supervision,
542            mode: None,
543            prompt: "Approve dispatch".to_owned(),
544            presents: json!([]),
545            decisions: None,
546            output_schema: None,
547            deadline_at_ms: None,
548        };
549        let wire = serde_json::to_value(&requested).expect("serialize");
550        assert_eq!(wire["type"], "humanRequested");
551        assert_eq!(wire["purpose"], "supervision");
552        let object = wire.as_object().expect("object");
553        assert!(!object.contains_key("mode"));
554        assert!(!object.contains_key("decisions"));
555        assert!(!object.contains_key("outputSchema"));
556        assert!(!object.contains_key("deadlineAtMs"));
557        let back: RunLogPayload = serde_json::from_value(wire).expect("deserialize");
558        assert_eq!(back, requested);
559
560        let responded: RunLogPayload = serde_json::from_value(json!({
561            "type": "humanResponded",
562            "requestId": "req-1",
563            "purpose": "supervision",
564            "response": {"decision": "proceed"},
565            "actor": "cli:dengfengwang",
566        }))
567        .expect("deserialize");
568        assert_eq!(responded.event_type(), "humanResponded");
569    }
570
571    #[test]
572    fn human_requested_step_purpose_carries_the_full_request_shape() {
573        let schema = crate::primitives::JsonSchemaDocument::new(json!({
574            "type": "object",
575            "properties": { "code": { "type": "string" } },
576            "required": ["code"]
577        }))
578        .expect("valid schema document");
579        let requested = RunLogPayload::HumanRequested {
580            request_id: "req-2".to_owned(),
581            purpose: HumanPurpose::Step,
582            mode: Some(HumanMode::ProvideInput),
583            prompt: "Enter the code".to_owned(),
584            presents: json!([{"kind": "value", "value": 1}]),
585            decisions: Some(vec!["approve".to_owned(), "reject".to_owned()]),
586            output_schema: Some(schema),
587            deadline_at_ms: Some(1_700_000_600_000),
588        };
589        let wire = serde_json::to_value(&requested).expect("serialize");
590        assert_eq!(wire["mode"], "provideInput");
591        assert_eq!(wire["decisions"], json!(["approve", "reject"]));
592        assert_eq!(wire["outputSchema"]["required"], json!(["code"]));
593        assert_eq!(wire["deadlineAtMs"], json!(1_700_000_600_000_u64));
594        let back: RunLogPayload = serde_json::from_value(wire).expect("deserialize");
595        assert_eq!(back, requested);
596    }
597
598    #[test]
599    fn all_seventeen_discriminants_are_distinct_and_stable() {
600        let mut seen = std::collections::BTreeSet::new();
601        for name in RunLogPayload::EVENT_TYPES {
602            assert!(seen.insert(name), "duplicate event type {name}");
603        }
604        assert_eq!(seen.len(), 17);
605    }
606
607    #[test]
608    fn envelope_round_trips() {
609        let event = RunLogEvent {
610            run_id: "run-1".to_owned(),
611            seq: 7,
612            at_ms: 1_700_000_000_000,
613            run_path: vec![],
614            payload: RunLogPayload::ActionIntent {
615                call_id: "c-1".to_owned(),
616                args_snapshot: json!({"x": 1}),
617                chain_index: None,
618                channel: None,
619                action_name: None,
620            },
621        };
622        let wire = serde_json::to_value(&event).expect("serialize");
623        assert_eq!(wire["payload"]["type"], "actionIntent");
624        assert_eq!(wire["seq"], 7);
625        let back: RunLogEvent = serde_json::from_value(wire).expect("deserialize");
626        assert_eq!(back, event);
627    }
628}