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};
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}