pointlock_store/fold.rs
1//! Deterministic RunLog → [`CheckpointView`] folding.
2//!
3//! `Checkpoint = deterministic fold of the RunLog` (spine §6.1); this module
4//! is that fold, exposed as the pure function [`fold_checkpoint`] so the
5//! rebuild channel (`pointlock inspect --rebuild-checkpoint`, 07 §3.3) and
6//! the write-path materialization share one implementation and can be
7//! equality-checked against each other.
8//!
9//! ## Fold inputs
10//!
11//! The 17-event union does not carry the root flow id or the provider
12//! binding, so the fold takes a [`RunMeta`] (the `run` table row written by
13//! [`crate::Store::begin_run`]) alongside the ordered events. Both inputs
14//! are immutable after `begin_run`, keeping the fold a pure function of
15//! durable state.
16//!
17//! ## Coverage rules (iron rule: explicit, never silent)
18//!
19//! Every one of the 17 event types is matched explicitly below. Events that
20//! do not change the view are handled as *documented no-ops*, not wildcard
21//! arms. Every [`StepRecord`] field has an event carrier (spine §6.1 M1
22//! note — no placeholders remain):
23//!
24//! - `StepRecord.effectHash` / `judgeHash` / `resolvedInputs`: harvested
25//! from the `stepEntered` payload.
26//! - `StepRecord.output`: harvested from the `stepExited` payload; a call
27//! step whose exit carries no output keeps the callee outputs harvested
28//! from `callFramePopped`.
29//! - `CallFrame.nextIndex`: a *body cursor* — advanced only when the
30//! exited step is a direct body child of the innermost frame (nested
31//! container children and iteration instances do not move it; M2).
32//! - `CallFrame.iterStack`: reconstructed from the open container spans —
33//! an in-flight span whose successor extends it with an `iteration`
34//! path frame is a live foreach; the `as` name comes from the
35//! container's `stepEntered` snapshot (`{ items, as }`, the runner's
36//! foreach carrier). No carrier ⇒ no IterState (never fabricated).
37//! - `CallFrame.vars` stays empty in the fold: `let` products have no
38//! dedicated event carrier (the `stepEntered` snapshot of a let step
39//! *is* the bindings object, but the fold is kind-agnostic); the runner
40//! re-seeds scope from the records on resume (documented divergence,
41//! pending the handler wave).
42//! - `binding.sessionLineage` / `binding.eventCursor`: copied verbatim from
43//! [`RunMeta`]; no event advances the cursor or appends a session
44//! generation yet (M1 scope).
45//! - `runResumed.alignmentReport` stays log-resident; the fold does not
46//! re-base completed records.
47
48use pointlock_ir::{
49 ActChannel, ActionExecution, ActionName, ActionOutcome, ActionOutcomeKind,
50 AssertionOutcomeRecord, AttemptRecord, BindingState, CallFrame, CheckpointView, ErrorClass,
51 EvidenceRef, ExecutionMode, FlowId, Frontier, Hash, HumanPending, HumanPurpose, IterState,
52 ObservationRecord, PathFrame, PendingIntent, RunLogEvent, RunLogPayload, RunPath, StepId,
53 StepRecord, StepState, StepVerdict, Verdict, VerdictStatus,
54};
55use serde_json::Value;
56use std::collections::BTreeMap;
57
58use crate::error::FoldError;
59
60/// Immutable per-run metadata (the `run` table row): the fold input the
61/// 17-event union does not carry — root flow id, provider binding seed, and
62/// the identity fields also present in `runStarted`.
63#[derive(Debug, Clone, PartialEq)]
64pub struct RunMeta {
65 /// The run's id.
66 pub run_id: String,
67 /// The root flow's id (source of the root [`CallFrame`]'s `flowId`;
68 /// `runStarted` does not carry it).
69 pub flow_id: FlowId,
70 /// Content hash of the executing IR.
71 pub ir_hash: Hash,
72 /// Digest of the bound capability lockfile.
73 pub lockfile_digest: Hash,
74 /// The run's input parameters.
75 pub params_snapshot: Value,
76 /// Provider binding seed (M0: copied into the view verbatim; no event
77 /// advances the cursor yet — M0-C).
78 pub binding: BindingState,
79 /// Run creation timestamp (ms since epoch); informational.
80 pub created_at_ms: u64,
81}
82
83/// Run lifecycle status — the `run.status` column's closed four-value set
84/// (07 §3.3 DDL CHECK constraint).
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum RunStatus {
87 /// The run is executing (also the `begin_run` seed value).
88 Running,
89 /// The run was suspended (`runSuspended`).
90 Suspended,
91 /// A human interaction is pending (`humanRequested`; covers both step
92 /// and supervision purposes — the discriminator lives in
93 /// `humanPending.purpose`, not at run level; R13).
94 AwaitingHuman,
95 /// The run finished (`runFinished`).
96 Finished,
97}
98
99impl RunStatus {
100 /// The stored string form (matches the DDL CHECK constraint verbatim).
101 pub fn as_str(&self) -> &'static str {
102 match self {
103 RunStatus::Running => "running",
104 RunStatus::Suspended => "suspended",
105 RunStatus::AwaitingHuman => "awaitingHuman",
106 RunStatus::Finished => "finished",
107 }
108 }
109
110 /// Parses the stored string form.
111 pub fn parse(value: &str) -> Option<Self> {
112 match value {
113 "running" => Some(RunStatus::Running),
114 "suspended" => Some(RunStatus::Suspended),
115 "awaitingHuman" => Some(RunStatus::AwaitingHuman),
116 "finished" => Some(RunStatus::Finished),
117 _ => None,
118 }
119 }
120}
121
122/// Result of a fold: the materializable view plus the run status the same
123/// event sequence implies (kept together so the write path and the rebuild
124/// self-check share one transition function).
125#[derive(Debug, Clone, PartialEq)]
126pub struct FoldedRun {
127 /// The deterministic checkpoint view.
128 pub view: CheckpointView,
129 /// The run status after the last event.
130 pub status: RunStatus,
131}
132
133/// Folds an ordered event sequence into a [`CheckpointView`] + run status.
134///
135/// Pure and deterministic: same `(meta, events)` in, same [`FoldedRun`]
136/// out. With zero events it returns the seeded pre-start view (empty frame
137/// stack, `pending` frontier at the root flow path). Structural violations
138/// return [`FoldError`] — see the module docs for the coverage rules.
139pub fn fold_checkpoint(meta: &RunMeta, events: &[RunLogEvent]) -> Result<FoldedRun, FoldError> {
140 Ok(fold_state(meta, events)?.finish())
141}
142
143/// Folds the full ledger into the terminal [`FoldState`] (not yet
144/// collapsed to a view). `Store::append_event` caches it per run so the
145/// next append folds exactly one event instead of the whole ledger —
146/// the single-writer invariant (I1) makes the carried state exact, and
147/// `verify_checkpoint` remains the from-scratch cross-check.
148pub(crate) fn fold_state(meta: &RunMeta, events: &[RunLogEvent]) -> Result<FoldState, FoldError> {
149 let mut state = FoldState::seed(meta);
150 let mut prev_seq: Option<u64> = None;
151 for event in events {
152 if event.run_id != meta.run_id {
153 return Err(FoldError::RunIdMismatch {
154 seq: event.seq,
155 expected: meta.run_id.clone(),
156 actual: event.run_id.clone(),
157 });
158 }
159 if let Some(prev) = prev_seq
160 && event.seq <= prev
161 {
162 return Err(FoldError::NonMonotonicSeq {
163 prev,
164 seq: event.seq,
165 });
166 }
167 prev_seq = Some(event.seq);
168 state.apply(event)?;
169 }
170 Ok(state)
171}
172
173/// Scratch record of the step currently being assembled between
174/// `stepEntered` and `stepExited`. Kept as a stack: a `call` step stays
175/// in flight while its callee's steps enter and exit above it.
176#[derive(Debug, Clone)]
177struct InFlightStep {
178 run_path: RunPath,
179 step_id: StepId,
180 effect_hash: Hash,
181 judge_hash: Hash,
182 resolved_inputs: Value,
183 attempts: Vec<AttemptRecord>,
184 /// Callee outputs harvested from `callFramePopped` (call steps); the
185 /// `stepExited` payload's own output takes precedence when present.
186 call_outputs: Option<Value>,
187 observations: Vec<ObservationRecord>,
188 evidence: Vec<EvidenceRef>,
189 assertion_outcomes: Vec<AssertionOutcomeRecord>,
190 verdict: Option<StepVerdict>,
191}
192
193/// (chainIndex, channel, actionName) of one `actionIntent` (item ②).
194type DispatchIdentity = (Option<u32>, Option<ActChannel>, Option<ActionName>);
195
196#[derive(Debug, Clone)]
197pub(crate) struct FoldState {
198 view: CheckpointView,
199 status: RunStatus,
200 root_flow_id: FlowId,
201 started: bool,
202 in_flight: Vec<InFlightStep>,
203 /// callId → the intent's dispatch identity (item ②): in-memory
204 /// carrier from `actionIntent` to the settling `attemptRecord`;
205 /// never persisted (the durable shapes stay unchanged).
206 intent_dispatch: BTreeMap<String, DispatchIdentity>,
207 /// The run-path prefix of each live frame (parallel to `view.frames`):
208 /// the root flow path, then each pushed call frame's event path. The
209 /// direct-body-child test for `nextIndex` needs it.
210 frame_paths: Vec<RunPath>,
211}
212
213impl FoldState {
214 fn seed(meta: &RunMeta) -> Self {
215 let root_path: RunPath = vec![PathFrame::Flow {
216 flow_id: meta.flow_id.clone(),
217 ir_hash: meta.ir_hash.clone(),
218 }];
219 FoldState {
220 view: CheckpointView {
221 run_id: meta.run_id.clone(),
222 ir_hash: meta.ir_hash.clone(),
223 lockfile_digest: meta.lockfile_digest.clone(),
224 params_snapshot: meta.params_snapshot.clone(),
225 binding: meta.binding.clone(),
226 completed: Vec::new(),
227 frames: Vec::new(),
228 frontier: Frontier {
229 run_path: root_path,
230 state: StepState::Pending,
231 pending_intent: None,
232 },
233 human_pending: None,
234 },
235 status: RunStatus::Running,
236 root_flow_id: meta.flow_id.clone(),
237 started: false,
238 in_flight: Vec::new(),
239 intent_dispatch: BTreeMap::new(),
240 frame_paths: Vec::new(),
241 }
242 }
243
244 pub(crate) fn finish(mut self) -> FoldedRun {
245 // Live foreach reconstruction (07 §3.2 iterStack): an in-flight
246 // span whose successor's run path extends it with an `iteration`
247 // frame is a live foreach round; the `as` name comes from the
248 // container's stepEntered snapshot ({ items, as }). No carrier ⇒
249 // no IterState (never fabricated).
250 for frame in &mut self.view.frames {
251 frame.iter_stack.clear();
252 }
253 for pair in self.in_flight.windows(2) {
254 let (parent, child) = (&pair[0], &pair[1]);
255 if child.run_path.len() <= parent.run_path.len() {
256 continue;
257 }
258 let extends = parent
259 .run_path
260 .iter()
261 .zip(child.run_path.iter())
262 .all(|(a, b)| same_site(a, b));
263 let Some(PathFrame::Iteration { index, key }) =
264 child.run_path.get(parent.run_path.len())
265 else {
266 continue;
267 };
268 let Some(var) = parent
269 .resolved_inputs
270 .get("as")
271 .and_then(Value::as_str)
272 .filter(|_| extends)
273 else {
274 continue;
275 };
276 // The IterState belongs to the innermost frame whose prefix
277 // covers the foreach span.
278 let owner = self.frame_paths.iter().rposition(|prefix| {
279 parent.run_path.len() >= prefix.len()
280 && prefix
281 .iter()
282 .zip(parent.run_path.iter())
283 .all(|(a, b)| same_site(a, b))
284 });
285 if let Some(owner) = owner
286 && owner < self.view.frames.len()
287 {
288 self.view.frames[owner].iter_stack.push(IterState {
289 var: var.to_owned(),
290 index: *index,
291 key: key.clone(),
292 });
293 }
294 }
295 FoldedRun {
296 view: self.view,
297 status: self.status,
298 }
299 }
300
301 /// Applies one event. All 17 payload variants are matched explicitly
302 /// (`callFramePushed` twice — its `rebase` discriminant selects between
303 /// opening a frame and re-entering one); no-op arms are documented as
304 /// such (M0 iron rule — nothing is silently ignored via a wildcard).
305 pub(crate) fn apply(&mut self, event: &RunLogEvent) -> Result<(), FoldError> {
306 let seq = event.seq;
307 if !self.started && !matches!(event.payload, RunLogPayload::RunStarted { .. }) {
308 return Err(FoldError::EventBeforeRunStarted {
309 seq,
310 event_type: event.payload.event_type(),
311 });
312 }
313 match &event.payload {
314 RunLogPayload::RunStarted {
315 ir_hash,
316 lockfile_digest,
317 params_snapshot,
318 // Per-segment supervision policy is log-resident audit data
319 // (spine §6.9): CheckpointView has no field for it.
320 supervise_policy: _,
321 } => {
322 if self.started {
323 return Err(FoldError::DuplicateRunStarted { seq });
324 }
325 self.started = true;
326 // The log is the truth: adopt the payload's identity fields
327 // (begin_run writes the same values into the run row).
328 self.view.ir_hash = ir_hash.clone();
329 self.view.lockfile_digest = lockfile_digest.clone();
330 self.view.params_snapshot = params_snapshot.clone();
331 // Root call frame: flowId comes from RunMeta (the payload
332 // does not carry it), inputs are the params snapshot
333 // (07 §3.2: the root frame references paramsSnapshot).
334 self.view.frames.push(CallFrame {
335 flow_id: self.root_flow_id.clone(),
336 ir_hash: ir_hash.clone(),
337 call_step_id: None,
338 inputs_snapshot: params_snapshot.clone(),
339 vars: Default::default(),
340 iter_stack: Vec::new(),
341 next_index: 0,
342 });
343 self.view.frontier = Frontier {
344 run_path: vec![PathFrame::Flow {
345 flow_id: self.root_flow_id.clone(),
346 ir_hash: ir_hash.clone(),
347 }],
348 state: StepState::Pending,
349 pending_intent: None,
350 };
351 self.frame_paths.push(vec![PathFrame::Flow {
352 flow_id: self.root_flow_id.clone(),
353 ir_hash: ir_hash.clone(),
354 }]);
355 self.status = RunStatus::Running;
356 }
357 RunLogPayload::StepEntered {
358 step_id,
359 effect_hash,
360 judge_hash,
361 resolved_inputs,
362 } => {
363 self.in_flight.push(InFlightStep {
364 run_path: event.run_path.clone(),
365 step_id: step_id.clone(),
366 effect_hash: effect_hash.clone(),
367 judge_hash: judge_hash.clone(),
368 resolved_inputs: resolved_inputs.clone(),
369 attempts: Vec::new(),
370 call_outputs: None,
371 observations: Vec::new(),
372 evidence: Vec::new(),
373 assertion_outcomes: Vec::new(),
374 verdict: None,
375 });
376 self.view.frontier = Frontier {
377 run_path: event.run_path.clone(),
378 state: StepState::Ready,
379 pending_intent: None,
380 };
381 }
382 RunLogPayload::PreflightProbed { outcomes } => {
383 // The probe outcomes stay log-resident (they are probes,
384 // not the step's assert-phase outcomes), but the frontier's
385 // STATE materializes (spine §6.2 / §6.6): a passed probe
386 // set leaves the step `probing` (the act overwrites it with
387 // `acting` moments later — the window is only visible when
388 // the run stops in it), and a missed one leaves it
389 // `drifted`, which is exactly what a checkpoint suspended
390 // on drift must say — 「resume probe failed; awaiting
391 // onResumeDrift disposition」 was unobservable before this
392 // arm wrote it.
393 //
394 // An EMPTY outcome list is the `unprobed` mark (07 §4.2
395 // rule 1), a note that nothing was checked — not a phase
396 // transition; the state stays whatever it was.
397 if !outcomes.is_empty() {
398 let missed = outcomes
399 .iter()
400 .any(|outcome| outcome.result != VerdictStatus::Pass);
401 self.view.frontier.state = if missed {
402 StepState::Drifted
403 } else {
404 StepState::Probing
405 };
406 }
407 }
408 RunLogPayload::ActionIntent {
409 call_id,
410 args_snapshot,
411 chain_index,
412 channel,
413 action_name,
414 } => {
415 // Fold-internal intent→settle carrier (2026-07-18
416 // incorporation, item ②): the durable PendingIntent shape
417 // stays unchanged (I1 on existing stores); the identity
418 // fields ride in memory keyed by callId, deterministic
419 // from events (the full-refold fallback reproduces it).
420 self.intent_dispatch.insert(
421 call_id.clone(),
422 (*chain_index, *channel, action_name.clone()),
423 );
424 // The crash-window key (07 §3.1): frontier records the
425 // hanging intent until the matching actionSettled.
426 self.view.frontier.state = StepState::Acting;
427 self.view.frontier.pending_intent = Some(PendingIntent {
428 call_id: call_id.clone(),
429 args_snapshot: args_snapshot.clone(),
430 });
431 }
432 RunLogPayload::ActionSettled { call_id, outcome } => {
433 let step =
434 self.in_flight
435 .last_mut()
436 .ok_or_else(|| FoldError::EventOutsideStep {
437 seq,
438 event_type: event.payload.event_type(),
439 })?;
440 let dispatch = self.intent_dispatch.remove(call_id).unwrap_or_default();
441 step.attempts
442 .push(attempt_record(call_id, outcome, dispatch));
443 // Clear the pending intent this terminal settles. A
444 // non-matching callId leaves the intent in place (a runner
445 // discipline breach worth surfacing at reconcile time, not
446 // papering over here).
447 if self
448 .view
449 .frontier
450 .pending_intent
451 .as_ref()
452 .is_some_and(|intent| intent.call_id == *call_id)
453 {
454 self.view.frontier.pending_intent = None;
455 }
456 self.view.frontier.state = StepState::Settling;
457 }
458 RunLogPayload::ObservationRecorded { observation } => {
459 let step =
460 self.in_flight
461 .last_mut()
462 .ok_or_else(|| FoldError::EventOutsideStep {
463 seq,
464 event_type: event.payload.event_type(),
465 })?;
466 if let Some(screenshot) = &observation.screenshot {
467 step.evidence.push(screenshot.clone());
468 }
469 if let Some(ui_snapshot) = &observation.ui_snapshot {
470 step.evidence.push(ui_snapshot.clone());
471 }
472 step.observations.push(observation.clone());
473 self.view.frontier.state = StepState::Observing;
474 }
475 RunLogPayload::AssertionEvaluated { outcome } => {
476 let step =
477 self.in_flight
478 .last_mut()
479 .ok_or_else(|| FoldError::EventOutsideStep {
480 seq,
481 event_type: event.payload.event_type(),
482 })?;
483 step.assertion_outcomes.push(outcome.clone());
484 self.view.frontier.state = StepState::Asserting;
485 }
486 RunLogPayload::VerdictRecorded {
487 verdict,
488 localized,
489 localization_gaps: _,
490 remote_archival_error: _,
491 } => {
492 // The judgment's localized manifest merges into the
493 // record's evidence (item ③, 2026-07-18): dedup key
494 // (sha256, asset.id), first occurrence wins — the same
495 // rule the dossier applies, so the two surfaces can
496 // never diverge on one ledger. Gaps stay log-resident
497 // (the dossier reads them from the event; the checkpoint
498 // carries successes only).
499 //
500 // The target is chosen by the event's OWN run path, never by
501 // "is anything in flight". A crash-opened span leaves an
502 // in-flight step that has nothing to do with an offline
503 // re-judgement written against a completed record, and
504 // attaching the verdict to it would silently overwrite a
505 // different step's judgment — the ledger would then say the
506 // crashed step was judged and the re-judged one was not.
507 // Every live `verdictRecorded` is appended at its own step's
508 // path (the same path its `stepEntered` used), so path
509 // equality selects exactly the target `last_mut()` used to
510 // select in every live case.
511 let in_flight_at_path = self
512 .in_flight
513 .iter()
514 .rposition(|step| same_instance(&step.run_path, &event.run_path));
515 if let Some(index) = in_flight_at_path {
516 let step = &mut self.in_flight[index];
517 merge_evidence(&mut step.evidence, localized);
518 step.verdict = Some(project_verdict(verdict));
519 self.view.frontier.state = StepState::Judged;
520 } else if let Some(record) = self
521 .view
522 .completed
523 .iter_mut()
524 .rev()
525 .find(|record| same_instance(&record.run_path, &event.run_path))
526 {
527 // Offline re-judgement (judgeDirty, spine §6.7-A): the
528 // log gets a *new* verdictRecorded with `supersedes`;
529 // the fold re-projects the completed record. Rejudge
530 // manifests are empty by construction (nothing is
531 // localized offline) — the merge is a no-op there,
532 // and the arm treats the field identically in both
533 // branches so incremental and full refolds agree.
534 merge_evidence(&mut record.evidence, localized);
535 record.verdict = Some(project_verdict(verdict));
536 } else {
537 return Err(FoldError::VerdictWithoutTarget { seq });
538 }
539 }
540 RunLogPayload::StepExited {
541 state,
542 output,
543 localized,
544 ..
545 } => {
546 let mut step = self
547 .in_flight
548 .pop()
549 .ok_or(FoldError::StepExitedWithoutEntry { seq })?;
550 // An unverified exit's manifest merges here (item ③
551 // review fix) — same rule as the verdict-borne one.
552 merge_evidence(&mut step.evidence, localized);
553 // A terminal exit of the awaiting step settles its pending
554 // request without a response — the lazy timeout settlement
555 // (verdict unknown) and the aborted disposition both take
556 // this path (06 §5.3).
557 if self
558 .view
559 .human_pending
560 .as_ref()
561 .is_some_and(|pending| pending.run_path == event.run_path)
562 {
563 self.view.human_pending = None;
564 }
565 // Every terminal exit leaves a record (judged / skipped /
566 // blocked / aborted alike): completion order == exit order.
567 self.view.completed.push(StepRecord {
568 run_path: step.run_path,
569 step_id: step.step_id,
570 // Harvested from the stepEntered carrier (spine §6.1
571 // M1 note).
572 effect_hash: step.effect_hash,
573 judge_hash: step.judge_hash,
574 attempts: step.attempts,
575 resolved_inputs: step.resolved_inputs,
576 // The exit's projected output wins; a call step whose
577 // exit carries none keeps the callee outputs from
578 // callFramePopped.
579 output: output.clone().or(step.call_outputs),
580 observations: step.observations,
581 evidence: step.evidence,
582 assertion_outcomes: step.assertion_outcomes,
583 verdict: step.verdict,
584 });
585 // Advance the innermost frame's *body* cursor — only when
586 // the exited step is a direct body child of that frame
587 // (nested container children and iteration instances do
588 // not move it; M2). While a callee runs, the innermost
589 // frame *is* the callee frame, so this lands on the right
590 // frame for nested exits too. Frame identity is compared
591 // site-wise (hash-insensitive) so a cross-IR resume
592 // segment keeps advancing the same frame.
593 let frame = self
594 .view
595 .frames
596 .last_mut()
597 .ok_or(FoldError::NoActiveFrame { seq })?;
598 if self
599 .frame_paths
600 .last()
601 .is_some_and(|prefix| direct_body_child(prefix, &event.run_path))
602 {
603 frame.next_index += 1;
604 }
605 self.view.frontier = Frontier {
606 run_path: event.run_path.clone(),
607 state: *state,
608 pending_intent: None,
609 };
610 }
611 RunLogPayload::CallFramePushed {
612 frame,
613 rebase: false,
614 } => {
615 self.view.frames.push(frame.clone());
616 self.frame_paths.push(event.run_path.clone());
617 }
618 RunLogPayload::CallFramePushed {
619 frame,
620 rebase: true,
621 } => {
622 // A live-frame re-entry under a repaired callee (07 §5.2
623 // case (a)) — NOT a new stack level. The addressed level is
624 // the event path's `call` depth, not the innermost frame: a
625 // resume walks back in from the root, so an outer frame is
626 // re-entered while the inner ones it once opened are still
627 // on the stack. Cross-IR safe by construction — the count
628 // reads the path's shape, never its hashes.
629 let level = event
630 .run_path
631 .iter()
632 .filter(|frame| matches!(frame, PathFrame::Call { .. }))
633 .count();
634 let depth = self.view.frames.len();
635 let Some(open) = self.view.frames.get_mut(level) else {
636 return Err(FoldError::RebaseWithoutFrame { seq, level, depth });
637 };
638 // ONLY the callee pin moves. `inputsSnapshot` above all
639 // stays put: a live frame's snapshot is never re-evaluated
640 // for a new IR (07 §5.2 corollary / §4.6), and the descent
641 // was licensed precisely because the `inputs` expressions
642 // did not change — so the archived values ARE the ones the
643 // new IR would produce. The body cursor, iteration stack
644 // and vars describe where the frame *is*, which a repaired
645 // callee does not move either.
646 open.ir_hash = frame.ir_hash.clone();
647 // `frame_paths` is left alone: it is only ever compared
648 // through `same_site`, which is hash-insensitive precisely
649 // so a cross-IR resume keeps matching the same site.
650 }
651 RunLogPayload::CallFramePopped { outputs } => {
652 if self.view.frames.len() <= 1 {
653 return Err(FoldError::PoppedRootFrame { seq });
654 }
655 self.view.frames.pop();
656 self.frame_paths.pop();
657 // The innermost in-flight step is the host call step (its
658 // callee's steps have all exited); the callee's outputs
659 // are the call step's output. Handler-repair frames have
660 // no host call step — nothing in flight, nothing to fill.
661 if let Some(step) = self.in_flight.last_mut() {
662 step.call_outputs = outputs.clone();
663 }
664 }
665 RunLogPayload::HandlerTriggered {
666 hook: _,
667 trigger: _,
668 disposition: _,
669 } => {
670 // Documented no-op: handler firing is audit data. The hook
671 // trace materializes through the `hook` frames of
672 // subsequent events' run paths, not as a view field.
673 }
674 RunLogPayload::HumanRequested {
675 request_id,
676 purpose,
677 mode,
678 prompt,
679 // The presented evidence/values and the response contract
680 // stay log-resident; HumanPending does not carry them
681 // (spine §6.6, 06 §4.3 reads them back from the event).
682 presents: _,
683 decisions: _,
684 output_schema: _,
685 deadline_at_ms,
686 } => {
687 self.view.human_pending = Some(HumanPending {
688 run_path: event.run_path.clone(),
689 request_id: request_id.clone(),
690 purpose: *purpose,
691 mode: *mode,
692 prompt: prompt.clone(),
693 deadline_at_ms: *deadline_at_ms,
694 });
695 self.view.frontier.state = StepState::AwaitingHuman;
696 self.status = RunStatus::AwaitingHuman;
697 }
698 RunLogPayload::HumanResponded {
699 request_id,
700 purpose,
701 response,
702 actor: _,
703 } => {
704 // Lazy settlement (spine §6.8): a response must pair the
705 // pending request; the arbitration result itself
706 // (response/actor) stays log-resident.
707 let paired = self
708 .view
709 .human_pending
710 .as_ref()
711 .is_some_and(|pending| pending.request_id == *request_id);
712 if !paired {
713 return Err(FoldError::UnpairedHumanResponse {
714 seq,
715 request_id: request_id.clone(),
716 });
717 }
718 // A supervision `suspend` answer is non-final (spine §6.9):
719 // the request stays pending across segments and the run
720 // keeps awaiting a proceed/abort ruling.
721 let retains = *purpose == HumanPurpose::Supervision
722 && response.get("decision").and_then(Value::as_str) == Some("suspend");
723 if retains {
724 self.status = RunStatus::AwaitingHuman;
725 } else {
726 self.view.human_pending = None;
727 // The frontier step state stays as-is: the follow-up
728 // event (actionIntent on supervision-proceed,
729 // verdictRecorded / stepExited on a human step) moves
730 // it.
731 self.status = RunStatus::Running;
732 }
733 }
734 RunLogPayload::RunSuspended { .. } => {
735 // Run-level status only; the frontier keeps its last
736 // step-level state so resume knows where the step stood.
737 // A suspension while a human request is pending keeps the
738 // run self-describing as awaitingHuman (spine §6.8: the
739 // wait is a legal suspend point).
740 self.status = if self.view.human_pending.is_some() {
741 RunStatus::AwaitingHuman
742 } else {
743 RunStatus::Suspended
744 };
745 }
746 RunLogPayload::RunResumed {
747 // Log-resident in M0: the fold does not re-base completed
748 // records from the alignment report (module docs; M0-C).
749 alignment_report: _,
750 // Per-segment policy, log-resident (as for runStarted).
751 supervise_policy: _,
752 event_cursor,
753 } => {
754 self.status = RunStatus::Running;
755 // 07 §4.5 (incorporated 2026-07-18): a cursor-bearing
756 // resume extends the lineage and reseeds the watermark.
757 // A cursor-less resume (old ledgers) changes nothing —
758 // the view names exactly what was recorded, never an
759 // invented generation (principle 4). Only new-binary
760 // ledgers carry the field, so stored views and refolds
761 // agree on every pre-incorporation store (I1).
762 if let Some(cursor) = event_cursor {
763 if self.view.binding.session_lineage.last() != Some(&cursor.session_id) {
764 self.view
765 .binding
766 .session_lineage
767 .push(cursor.session_id.clone());
768 }
769 self.view.binding.event_cursor = cursor.clone();
770 }
771 }
772 RunLogPayload::RunFinished {
773 verdict: _,
774 remote_archival_error: _,
775 } => {
776 // The folded flow verdict stays log-resident; the view has
777 // no field for it (reports read it from the log).
778 self.status = RunStatus::Finished;
779 }
780 }
781 Ok(())
782 }
783}
784
785/// Whether `path` addresses a direct body child of the frame rooted at
786/// `prefix`: exactly one extra frame, and that frame is a step or a call
787/// (iteration instances and nested container children are not body
788/// children).
789fn direct_body_child(prefix: &RunPath, path: &RunPath) -> bool {
790 path.len() == prefix.len() + 1
791 && prefix.iter().zip(path.iter()).all(|(a, b)| same_site(a, b))
792 && matches!(
793 path.last(),
794 Some(PathFrame::Step { .. }) | Some(PathFrame::Call { .. })
795 )
796}
797
798/// Whether two run paths address the SAME step instance.
799///
800/// Hash-insensitive by way of [`same_site`], because a cross-IR resume
801/// rewrites the flow and callee hashes of a path whose sites are unchanged:
802/// a step whose span was opened by the crashed segment carries the OLD
803/// flow's hashes, while the events the resume appends carry the new ones.
804/// Both branches of the `verdictRecorded` arm use this one notion — if they
805/// disagreed, a path could match neither and a legitimate verdict would
806/// fold to `VerdictWithoutTarget`.
807fn same_instance(a: &[PathFrame], b: &[PathFrame]) -> bool {
808 a.len() == b.len() && a.iter().zip(b.iter()).all(|(x, y)| same_site(x, y))
809}
810
811/// Site-wise path-frame identity: hash-insensitive (a cross-IR resume
812/// changes the flow/callee hashes of the same site), position-sensitive.
813fn same_site(a: &PathFrame, b: &PathFrame) -> bool {
814 match (a, b) {
815 (PathFrame::Flow { flow_id: a, .. }, PathFrame::Flow { flow_id: b, .. }) => a == b,
816 (PathFrame::Step { step_id: a }, PathFrame::Step { step_id: b }) => a == b,
817 (
818 PathFrame::Call {
819 step_id: a,
820 callee_flow_id: af,
821 ..
822 },
823 PathFrame::Call {
824 step_id: b,
825 callee_flow_id: bf,
826 ..
827 },
828 ) => a == b && af == bf,
829 (
830 PathFrame::Iteration { index: a, key: ak },
831 PathFrame::Iteration { index: b, key: bk },
832 ) => a == b && ak == bk,
833 (a, b) => a == b,
834 }
835}
836
837/// Merges a judgment's localized manifest into a record's evidence:
838/// dedup key (sha256, asset.id), first occurrence wins (item ③ — one
839/// rule for checkpoint and dossier).
840fn merge_evidence(evidence: &mut Vec<EvidenceRef>, localized: &[EvidenceRef]) {
841 for entry in localized {
842 let duplicate = evidence
843 .iter()
844 .any(|existing| existing.sha256 == entry.sha256 && existing.asset.id == entry.asset.id);
845 if !duplicate {
846 evidence.push(entry.clone());
847 }
848 }
849}
850
851/// Projects a four-way terminal into the durable [`AttemptRecord`]
852/// (spine §6.6): discriminant + best-effort classification. The full
853/// outcome stays in the `actionSettled` payload.
854fn attempt_record(
855 call_id: &str,
856 outcome: &ActionOutcome,
857 dispatch: DispatchIdentity,
858) -> AttemptRecord {
859 let kind = match outcome {
860 ActionOutcome::Succeeded { .. } => ActionOutcomeKind::Succeeded,
861 ActionOutcome::Failed { .. } => ActionOutcomeKind::Failed,
862 ActionOutcome::Cancelled { .. } => ActionOutcomeKind::Cancelled,
863 ActionOutcome::TimedOut { .. } => ActionOutcomeKind::TimedOut,
864 };
865 // Best-effort M0 classification: ErrorInfo.code is an open string the
866 // provider adapter maps onto the closed ErrorClass; when the code
867 // already spells a class verbatim we adopt it, otherwise None (a
868 // dedicated carrier is M0-C).
869 let error_class = match outcome {
870 ActionOutcome::Succeeded { .. } => None,
871 ActionOutcome::Failed { error }
872 | ActionOutcome::Cancelled { error }
873 | ActionOutcome::TimedOut { error } => parse_error_class(&error.code),
874 };
875 let (execution_mode, fallback_reason) = match outcome {
876 ActionOutcome::Succeeded { result } => match &result.execution {
877 Some(ActionExecution::NativeSemantic { .. }) => {
878 (Some(ExecutionMode::NativeSemantic), None)
879 }
880 Some(ActionExecution::WebSemantic { .. }) => (Some(ExecutionMode::WebSemantic), None),
881 Some(ActionExecution::CoordinateFallback {
882 fallback_reason, ..
883 }) => (
884 Some(ExecutionMode::CoordinateFallback),
885 Some(*fallback_reason),
886 ),
887 None => (None, None),
888 },
889 _ => (None, None),
890 };
891 let (chain_index, channel, action_name) = dispatch;
892 AttemptRecord {
893 call_id: call_id.to_owned(),
894 outcome: kind,
895 error_class,
896 execution_mode,
897 fallback_reason,
898 chain_index,
899 channel,
900 action_name,
901 }
902}
903
904fn parse_error_class(code: &str) -> Option<ErrorClass> {
905 serde_json::from_value(Value::String(code.to_owned())).ok()
906}
907
908/// Projects a folded [`Verdict`] onto the durable per-step [`StepVerdict`]
909/// (spine §6.6: summary/evidence stay in the `verdictRecorded` payload).
910fn project_verdict(verdict: &Verdict) -> StepVerdict {
911 StepVerdict {
912 status: verdict.status,
913 degraded: verdict.degraded,
914 supersedes: verdict.supersedes.clone(),
915 }
916}
917
918#[cfg(test)]
919mod tests {
920 use pointlock_ir::{BindingState, EventCursor, SupervisePolicy};
921 use serde_json::json;
922
923 use super::*;
924
925 fn hash(fill: char) -> Hash {
926 Hash::new(format!("sha256:{}", fill.to_string().repeat(64))).expect("valid hash")
927 }
928
929 fn meta() -> RunMeta {
930 RunMeta {
931 run_id: "run-1".to_owned(),
932 flow_id: FlowId::new("checkout").expect("valid flow id"),
933 ir_hash: hash('a'),
934 lockfile_digest: hash('b'),
935 params_snapshot: json!({"user": "alice"}),
936 binding: BindingState {
937 device_id: "dev-1".to_owned(),
938 session_lineage: vec!["s-1".to_owned()],
939 event_cursor: EventCursor {
940 session_id: "s-1".to_owned(),
941 last_sequence: 0,
942 },
943 },
944 created_at_ms: 1,
945 }
946 }
947
948 fn event(seq: u64, run_path: RunPath, payload: RunLogPayload) -> RunLogEvent {
949 RunLogEvent {
950 run_id: "run-1".to_owned(),
951 seq,
952 at_ms: 1_000 + seq,
953 run_path,
954 payload,
955 }
956 }
957
958 fn run_started() -> RunLogPayload {
959 RunLogPayload::RunStarted {
960 ir_hash: hash('a'),
961 lockfile_digest: hash('b'),
962 params_snapshot: json!({"user": "alice"}),
963 supervise_policy: Some(SupervisePolicy::Mutating),
964 }
965 }
966
967 #[test]
968 fn zero_events_fold_to_the_seeded_pre_start_view() {
969 let folded = fold_checkpoint(&meta(), &[]).expect("fold");
970 assert!(folded.view.frames.is_empty());
971 assert_eq!(folded.view.frontier.state, StepState::Pending);
972 assert_eq!(folded.status, RunStatus::Running);
973 }
974
975 #[test]
976 fn run_started_initializes_the_root_frame_from_meta_flow_id() {
977 let folded = fold_checkpoint(&meta(), &[event(1, vec![], run_started())]).expect("fold");
978 assert_eq!(folded.view.frames.len(), 1);
979 assert_eq!(folded.view.frames[0].flow_id.as_str(), "checkout");
980 assert_eq!(
981 folded.view.frames[0].inputs_snapshot,
982 json!({"user": "alice"})
983 );
984 assert_eq!(folded.view.frames[0].next_index, 0);
985 }
986
987 #[test]
988 fn events_before_run_started_are_rejected() {
989 let err = fold_checkpoint(
990 &meta(),
991 &[event(
992 1,
993 vec![],
994 RunLogPayload::StepEntered {
995 step_id: StepId::new("login").expect("valid step id"),
996 effect_hash: hash('c'),
997 judge_hash: hash('d'),
998 resolved_inputs: json!({}),
999 },
1000 )],
1001 )
1002 .expect_err("must reject");
1003 assert_eq!(
1004 err,
1005 FoldError::EventBeforeRunStarted {
1006 seq: 1,
1007 event_type: "stepEntered"
1008 }
1009 );
1010 }
1011
1012 #[test]
1013 fn duplicate_run_started_is_rejected() {
1014 let err = fold_checkpoint(
1015 &meta(),
1016 &[
1017 event(1, vec![], run_started()),
1018 event(2, vec![], run_started()),
1019 ],
1020 )
1021 .expect_err("must reject");
1022 assert_eq!(err, FoldError::DuplicateRunStarted { seq: 2 });
1023 }
1024
1025 #[test]
1026 fn non_monotonic_seq_is_rejected() {
1027 let err = fold_checkpoint(
1028 &meta(),
1029 &[
1030 event(1, vec![], run_started()),
1031 event(
1032 1,
1033 vec![],
1034 RunLogPayload::RunSuspended {
1035 provider_state_summary: None,
1036 reason: None,
1037 },
1038 ),
1039 ],
1040 )
1041 .expect_err("must reject");
1042 assert_eq!(err, FoldError::NonMonotonicSeq { prev: 1, seq: 1 });
1043 }
1044
1045 #[test]
1046 fn step_exited_without_entry_is_rejected() {
1047 let err = fold_checkpoint(
1048 &meta(),
1049 &[
1050 event(1, vec![], run_started()),
1051 event(
1052 2,
1053 vec![],
1054 RunLogPayload::StepExited {
1055 provider_state_summary: None,
1056 state: StepState::Judged,
1057 output: None,
1058 localized: Vec::new(),
1059 localization_gaps: Vec::new(),
1060 },
1061 ),
1062 ],
1063 )
1064 .expect_err("must reject");
1065 assert_eq!(err, FoldError::StepExitedWithoutEntry { seq: 2 });
1066 }
1067
1068 #[test]
1069 fn step_record_fields_are_harvested_from_the_carrier_events() {
1070 let step_path: RunPath = vec![PathFrame::Step {
1071 step_id: StepId::new("login").expect("valid step id"),
1072 }];
1073 let folded = fold_checkpoint(
1074 &meta(),
1075 &[
1076 event(1, vec![], run_started()),
1077 event(
1078 2,
1079 step_path.clone(),
1080 RunLogPayload::StepEntered {
1081 step_id: StepId::new("login").expect("valid step id"),
1082 effect_hash: hash('c'),
1083 judge_hash: hash('d'),
1084 resolved_inputs: json!({"element": {"identifier": "loginButton"}}),
1085 },
1086 ),
1087 event(
1088 3,
1089 step_path.clone(),
1090 RunLogPayload::StepExited {
1091 provider_state_summary: None,
1092 state: StepState::Judged,
1093 output: Some(json!({"ok": true})),
1094 localized: Vec::new(),
1095 localization_gaps: Vec::new(),
1096 },
1097 ),
1098 ],
1099 )
1100 .expect("fold");
1101 let record = &folded.view.completed[0];
1102 assert_eq!(record.effect_hash, hash('c'));
1103 assert_eq!(record.judge_hash, hash('d'));
1104 assert_eq!(
1105 record.resolved_inputs,
1106 json!({"element": {"identifier": "loginButton"}})
1107 );
1108 assert_eq!(record.output, Some(json!({"ok": true})));
1109 }
1110
1111 #[test]
1112 fn popping_the_root_frame_is_rejected() {
1113 let err = fold_checkpoint(
1114 &meta(),
1115 &[
1116 event(1, vec![], run_started()),
1117 event(2, vec![], RunLogPayload::CallFramePopped { outputs: None }),
1118 ],
1119 )
1120 .expect_err("must reject");
1121 assert_eq!(err, FoldError::PoppedRootFrame { seq: 2 });
1122 }
1123
1124 #[test]
1125 fn unpaired_human_response_is_rejected() {
1126 let err = fold_checkpoint(
1127 &meta(),
1128 &[
1129 event(1, vec![], run_started()),
1130 event(
1131 2,
1132 vec![],
1133 RunLogPayload::HumanResponded {
1134 request_id: "req-ghost".to_owned(),
1135 purpose: pointlock_ir::HumanPurpose::Step,
1136 response: json!({}),
1137 actor: "cli:tester".to_owned(),
1138 },
1139 ),
1140 ],
1141 )
1142 .expect_err("must reject");
1143 assert_eq!(
1144 err,
1145 FoldError::UnpairedHumanResponse {
1146 seq: 2,
1147 request_id: "req-ghost".to_owned()
1148 }
1149 );
1150 }
1151
1152 fn human_requested(request_id: &str, purpose: HumanPurpose) -> RunLogPayload {
1153 RunLogPayload::HumanRequested {
1154 request_id: request_id.to_owned(),
1155 purpose,
1156 mode: match purpose {
1157 HumanPurpose::Step => Some(pointlock_ir::HumanMode::Confirm),
1158 HumanPurpose::Supervision => None,
1159 },
1160 prompt: "Decide".to_owned(),
1161 presents: json!([]),
1162 decisions: None,
1163 output_schema: None,
1164 deadline_at_ms: match purpose {
1165 HumanPurpose::Step => Some(9_000),
1166 HumanPurpose::Supervision => None,
1167 },
1168 }
1169 }
1170
1171 #[test]
1172 fn supervision_suspend_answer_keeps_the_request_pending() {
1173 let step_path: RunPath = vec![PathFrame::Step {
1174 step_id: StepId::new("pay").expect("valid step id"),
1175 }];
1176 let folded = fold_checkpoint(
1177 &meta(),
1178 &[
1179 event(1, vec![], run_started()),
1180 event(
1181 2,
1182 step_path.clone(),
1183 human_requested("req-1", HumanPurpose::Supervision),
1184 ),
1185 event(
1186 3,
1187 step_path.clone(),
1188 RunLogPayload::HumanResponded {
1189 request_id: "req-1".to_owned(),
1190 purpose: HumanPurpose::Supervision,
1191 response: json!({"decision": "suspend"}),
1192 actor: "cli:tester".to_owned(),
1193 },
1194 ),
1195 // The suspend ruling parks the run; the request survives.
1196 event(
1197 4,
1198 vec![],
1199 RunLogPayload::RunSuspended {
1200 provider_state_summary: None,
1201 reason: None,
1202 },
1203 ),
1204 ],
1205 )
1206 .expect("fold");
1207 let pending = folded.view.human_pending.expect("request stays pending");
1208 assert_eq!(pending.request_id, "req-1");
1209 assert_eq!(folded.status, RunStatus::AwaitingHuman);
1210
1211 // A later final ruling still pairs and settles the wait.
1212 let folded = fold_checkpoint(
1213 &meta(),
1214 &[
1215 event(1, vec![], run_started()),
1216 event(
1217 2,
1218 step_path.clone(),
1219 human_requested("req-1", HumanPurpose::Supervision),
1220 ),
1221 event(
1222 3,
1223 step_path.clone(),
1224 RunLogPayload::HumanResponded {
1225 request_id: "req-1".to_owned(),
1226 purpose: HumanPurpose::Supervision,
1227 response: json!({"decision": "suspend"}),
1228 actor: "cli:tester".to_owned(),
1229 },
1230 ),
1231 event(
1232 4,
1233 step_path,
1234 RunLogPayload::HumanResponded {
1235 request_id: "req-1".to_owned(),
1236 purpose: HumanPurpose::Supervision,
1237 response: json!({"decision": "proceed"}),
1238 actor: "cli:tester".to_owned(),
1239 },
1240 ),
1241 ],
1242 )
1243 .expect("fold");
1244 assert!(folded.view.human_pending.is_none());
1245 assert_eq!(folded.status, RunStatus::Running);
1246 }
1247
1248 #[test]
1249 fn run_suspended_while_a_request_is_pending_stays_awaiting_human() {
1250 let step_path: RunPath = vec![PathFrame::Step {
1251 step_id: StepId::new("ask").expect("valid step id"),
1252 }];
1253 let folded = fold_checkpoint(
1254 &meta(),
1255 &[
1256 event(1, vec![], run_started()),
1257 event(2, step_path, human_requested("req-2", HumanPurpose::Step)),
1258 event(
1259 3,
1260 vec![],
1261 RunLogPayload::RunSuspended {
1262 provider_state_summary: None,
1263 reason: None,
1264 },
1265 ),
1266 ],
1267 )
1268 .expect("fold");
1269 assert_eq!(folded.status, RunStatus::AwaitingHuman);
1270 let pending = folded.view.human_pending.expect("pending");
1271 assert_eq!(pending.deadline_at_ms, Some(9_000));
1272 assert_eq!(pending.mode, Some(pointlock_ir::HumanMode::Confirm));
1273 }
1274
1275 #[test]
1276 fn step_exit_settles_the_pending_request_without_a_response() {
1277 // The lazy timeout settlement shape: the awaiting step exits
1278 // (verdict unknown) with no humanResponded on the ledger.
1279 let step_path: RunPath = vec![PathFrame::Step {
1280 step_id: StepId::new("ask").expect("valid step id"),
1281 }];
1282 let folded = fold_checkpoint(
1283 &meta(),
1284 &[
1285 event(1, vec![], run_started()),
1286 event(
1287 2,
1288 step_path.clone(),
1289 RunLogPayload::StepEntered {
1290 step_id: StepId::new("ask").expect("valid step id"),
1291 effect_hash: hash('c'),
1292 judge_hash: hash('d'),
1293 resolved_inputs: json!({"presents": []}),
1294 },
1295 ),
1296 event(
1297 3,
1298 step_path.clone(),
1299 human_requested("req-3", HumanPurpose::Step),
1300 ),
1301 event(
1302 4,
1303 vec![],
1304 RunLogPayload::RunSuspended {
1305 provider_state_summary: None,
1306 reason: None,
1307 },
1308 ),
1309 event(
1310 5,
1311 vec![],
1312 RunLogPayload::RunResumed {
1313 alignment_report: pointlock_ir::AlignmentReport {
1314 entries: vec![],
1315 resume_point: None,
1316 requires_confirmation: vec![],
1317 },
1318 supervise_policy: None,
1319 event_cursor: None,
1320 },
1321 ),
1322 event(
1323 6,
1324 step_path,
1325 RunLogPayload::StepExited {
1326 provider_state_summary: None,
1327 state: StepState::Judged,
1328 output: None,
1329 localized: Vec::new(),
1330 localization_gaps: Vec::new(),
1331 },
1332 ),
1333 ],
1334 )
1335 .expect("fold");
1336 assert!(folded.view.human_pending.is_none());
1337 assert_eq!(folded.status, RunStatus::Running);
1338 }
1339
1340 #[test]
1341 fn error_class_is_adopted_only_when_the_code_spells_a_class() {
1342 assert_eq!(
1343 parse_error_class("action_failed_final"),
1344 Some(ErrorClass::ActionFailedFinal)
1345 );
1346 assert_eq!(parse_error_class("SOME_DAEMON_CODE"), None);
1347 }
1348}