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