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}