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}