Skip to main content

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                // Pair the exit with the innermost in-flight entry at the
547                // exit's OWN path, never with whatever is on top. Every
548                // live exit is LIFO, but a crash-opened span whose step the
549                // repaired IR no longer reaches (renamed, deleted, moved)
550                // is never re-entered and never closed: it stays under its
551                // container, and a blind pop would hand the container's
552                // exit (state, output) to the orphan's record while the
553                // container itself gets none — and is then re-executed on
554                // every later resume. Site-wise match, as for verdicts.
555                let index = self
556                    .in_flight
557                    .iter()
558                    .rposition(|step| same_instance(&step.run_path, &event.run_path))
559                    .ok_or(FoldError::StepExitedWithoutEntry { seq })?;
560                let mut step = self.in_flight.remove(index);
561                // An unverified exit's manifest merges here (item ③
562                // review fix) — same rule as the verdict-borne one.
563                merge_evidence(&mut step.evidence, localized);
564                // A terminal exit of the awaiting step settles its pending
565                // request without a response — the lazy timeout settlement
566                // (verdict unknown) and the aborted disposition both take
567                // this path (06 §5.3).
568                if self
569                    .view
570                    .human_pending
571                    .as_ref()
572                    .is_some_and(|pending| exit_settles_pending(&event.run_path, &pending.run_path))
573                {
574                    self.view.human_pending = None;
575                }
576                // Every terminal exit leaves a record (judged / skipped /
577                // blocked / aborted alike): completion order == exit order.
578                self.view.completed.push(StepRecord {
579                    run_path: step.run_path,
580                    step_id: step.step_id,
581                    // Harvested from the stepEntered carrier (spine §6.1
582                    // M1 note).
583                    effect_hash: step.effect_hash,
584                    judge_hash: step.judge_hash,
585                    attempts: step.attempts,
586                    resolved_inputs: step.resolved_inputs,
587                    // The exit's projected output wins; a call step whose
588                    // exit carries none keeps the callee outputs from
589                    // callFramePopped.
590                    output: output.clone().or(step.call_outputs),
591                    observations: step.observations,
592                    evidence: step.evidence,
593                    assertion_outcomes: step.assertion_outcomes,
594                    verdict: step.verdict,
595                });
596                // Advance the innermost frame's *body* cursor — only when
597                // the exited step is a direct body child of that frame
598                // (nested container children and iteration instances do
599                // not move it; M2). While a callee runs, the innermost
600                // frame *is* the callee frame, so this lands on the right
601                // frame for nested exits too. Frame identity is compared
602                // site-wise (hash-insensitive) so a cross-IR resume
603                // segment keeps advancing the same frame.
604                let frame = self
605                    .view
606                    .frames
607                    .last_mut()
608                    .ok_or(FoldError::NoActiveFrame { seq })?;
609                if self
610                    .frame_paths
611                    .last()
612                    .is_some_and(|prefix| direct_body_child(prefix, &event.run_path))
613                {
614                    frame.next_index += 1;
615                }
616                self.view.frontier = Frontier {
617                    run_path: event.run_path.clone(),
618                    state: *state,
619                    pending_intent: None,
620                };
621            }
622            RunLogPayload::CallFramePushed {
623                frame,
624                rebase: false,
625            } => {
626                self.view.frames.push(frame.clone());
627                self.frame_paths.push(event.run_path.clone());
628            }
629            RunLogPayload::CallFramePushed {
630                frame,
631                rebase: true,
632            } => {
633                // A live-frame re-entry under a repaired callee (07 §5.2
634                // case (a)) — NOT a new stack level. The addressed level is
635                // the event path's `call` depth, not the innermost frame: a
636                // resume walks back in from the root, so an outer frame is
637                // re-entered while the inner ones it once opened are still
638                // on the stack. Cross-IR safe by construction — the count
639                // reads the path's shape, never its hashes.
640                let level = event
641                    .run_path
642                    .iter()
643                    .filter(|frame| matches!(frame, PathFrame::Call { .. }))
644                    .count();
645                let depth = self.view.frames.len();
646                let Some(open) = self.view.frames.get_mut(level) else {
647                    return Err(FoldError::RebaseWithoutFrame { seq, level, depth });
648                };
649                // ONLY the callee pin moves. `inputsSnapshot` above all
650                // stays put: a live frame's snapshot is never re-evaluated
651                // for a new IR (07 §5.2 corollary / §4.6), and the descent
652                // was licensed precisely because the `inputs` expressions
653                // did not change — so the archived values ARE the ones the
654                // new IR would produce. The body cursor, iteration stack
655                // and vars describe where the frame *is*, which a repaired
656                // callee does not move either.
657                open.ir_hash = frame.ir_hash.clone();
658                // `frame_paths` is left alone: it is only ever compared
659                // through `same_site`, which is hash-insensitive precisely
660                // so a cross-IR resume keeps matching the same site.
661            }
662            RunLogPayload::CallFramePopped { outputs } => {
663                if self.view.frames.len() <= 1 {
664                    return Err(FoldError::PoppedRootFrame { seq });
665                }
666                self.view.frames.pop();
667                self.frame_paths.pop();
668                // The innermost in-flight step is the host call step (its
669                // callee's steps have all exited); the callee's outputs
670                // are the call step's output. Handler-repair frames have
671                // no host call step — nothing in flight, nothing to fill.
672                if let Some(step) = self.in_flight.last_mut() {
673                    step.call_outputs = outputs.clone();
674                }
675            }
676            RunLogPayload::HandlerTriggered {
677                hook: _,
678                trigger: _,
679                disposition: _,
680            } => {
681                // Documented no-op: handler firing is audit data. The hook
682                // trace materializes through the `hook` frames of
683                // subsequent events' run paths, not as a view field.
684            }
685            RunLogPayload::HumanRequested {
686                request_id,
687                purpose,
688                mode,
689                prompt,
690                // The presented evidence/values and the response contract
691                // stay log-resident; HumanPending does not carry them
692                // (spine §6.6, 06 §4.3 reads them back from the event).
693                presents: _,
694                decisions: _,
695                output_schema: _,
696                deadline_at_ms,
697            } => {
698                self.view.human_pending = Some(HumanPending {
699                    run_path: event.run_path.clone(),
700                    request_id: request_id.clone(),
701                    purpose: *purpose,
702                    mode: *mode,
703                    prompt: prompt.clone(),
704                    deadline_at_ms: *deadline_at_ms,
705                });
706                self.view.frontier.state = StepState::AwaitingHuman;
707                self.status = RunStatus::AwaitingHuman;
708            }
709            RunLogPayload::HumanResponded {
710                request_id,
711                purpose,
712                response,
713                actor: _,
714            } => {
715                // Lazy settlement (spine §6.8): a response must pair the
716                // pending request; the arbitration result itself
717                // (response/actor) stays log-resident.
718                let paired = self
719                    .view
720                    .human_pending
721                    .as_ref()
722                    .is_some_and(|pending| pending.request_id == *request_id);
723                if !paired {
724                    return Err(FoldError::UnpairedHumanResponse {
725                        seq,
726                        request_id: request_id.clone(),
727                    });
728                }
729                // A supervision `suspend` answer is non-final (spine §6.9):
730                // the request stays pending across segments and the run
731                // keeps awaiting a proceed/abort ruling.
732                let retains = *purpose == HumanPurpose::Supervision
733                    && response.get("decision").and_then(Value::as_str) == Some("suspend");
734                if retains {
735                    self.status = RunStatus::AwaitingHuman;
736                } else {
737                    self.view.human_pending = None;
738                    // The frontier step state stays as-is: the follow-up
739                    // event (actionIntent on supervision-proceed,
740                    // verdictRecorded / stepExited on a human step) moves
741                    // it.
742                    self.status = RunStatus::Running;
743                }
744            }
745            RunLogPayload::RunSuspended { .. } => {
746                // Run-level status only; the frontier keeps its last
747                // step-level state so resume knows where the step stood.
748                // A suspension while a human request is pending keeps the
749                // run self-describing as awaitingHuman (spine §6.8: the
750                // wait is a legal suspend point).
751                self.status = if self.view.human_pending.is_some() {
752                    RunStatus::AwaitingHuman
753                } else {
754                    RunStatus::Suspended
755                };
756            }
757            RunLogPayload::RunResumed {
758                // Log-resident in M0: the fold does not re-base completed
759                // records from the alignment report (module docs; M0-C).
760                alignment_report: _,
761                // Per-segment policy, log-resident (as for runStarted).
762                supervise_policy: _,
763                event_cursor,
764            } => {
765                self.status = RunStatus::Running;
766                // 07 §4.5 (incorporated 2026-07-18): a cursor-bearing
767                // resume extends the lineage and reseeds the watermark.
768                // A cursor-less resume (old ledgers) changes nothing —
769                // the view names exactly what was recorded, never an
770                // invented generation (principle 4). Only new-binary
771                // ledgers carry the field, so stored views and refolds
772                // agree on every pre-incorporation store (I1).
773                if let Some(cursor) = event_cursor {
774                    if self.view.binding.session_lineage.last() != Some(&cursor.session_id) {
775                        self.view
776                            .binding
777                            .session_lineage
778                            .push(cursor.session_id.clone());
779                    }
780                    self.view.binding.event_cursor = cursor.clone();
781                }
782            }
783            RunLogPayload::RunFinished {
784                verdict: _,
785                remote_archival_error: _,
786            } => {
787                // The folded flow verdict stays log-resident; the view has
788                // no field for it (reports read it from the log).
789                self.status = RunStatus::Finished;
790            }
791        }
792        Ok(())
793    }
794}
795
796/// Whether `path` addresses a direct body child of the frame rooted at
797/// `prefix`: exactly one extra frame, and that frame is a step or a call
798/// (iteration instances and nested container children are not body
799/// children).
800fn direct_body_child(prefix: &RunPath, path: &RunPath) -> bool {
801    path.len() == prefix.len() + 1
802        && prefix.iter().zip(path.iter()).all(|(a, b)| same_site(a, b))
803        && matches!(
804            path.last(),
805            Some(PathFrame::Step { .. }) | Some(PathFrame::Call { .. })
806        )
807}
808
809/// Whether two run paths address the SAME step instance.
810///
811/// Hash-insensitive by way of [`same_site`], because a cross-IR resume
812/// rewrites the flow and callee hashes of a path whose sites are unchanged:
813/// a step whose span was opened by the crashed segment carries the OLD
814/// flow's hashes, while the events the resume appends carry the new ones.
815/// Both branches of the `verdictRecorded` arm use this one notion — if they
816/// disagreed, a path could match neither and a legitimate verdict would
817/// fold to `VerdictWithoutTarget`.
818fn same_instance(a: &[PathFrame], b: &[PathFrame]) -> bool {
819    a.len() == b.len() && a.iter().zip(b.iter()).all(|(x, y)| same_site(x, y))
820}
821
822/// Whether a `stepExited` at `exited` settles the pending human request
823/// anchored at `pending`: the exited step IS the awaiting step, or an
824/// ancestor of it. Two ledger shapes need more than exact path equality:
825/// an escalate hook's human is anchored at `<host>/hook:…/<human>` and
826/// settled in memory when the HOST exits (no event at the hook path), and
827/// a cross-IR resume exits the awaiting step at a path carrying the NEW
828/// flow hashes while the request was recorded under the old ones. Hence
829/// site-wise prefix, through [`same_instance`]. The inbox and overview
830/// projections share this one rule so the three surfaces cannot diverge.
831pub(crate) fn exit_settles_pending(exited: &[PathFrame], pending: &[PathFrame]) -> bool {
832    pending.len() >= exited.len() && same_instance(&pending[..exited.len()], exited)
833}
834
835/// Site-wise path-frame identity: hash-insensitive (a cross-IR resume
836/// changes the flow/callee hashes of the same site), position-sensitive.
837fn same_site(a: &PathFrame, b: &PathFrame) -> bool {
838    match (a, b) {
839        (PathFrame::Flow { flow_id: a, .. }, PathFrame::Flow { flow_id: b, .. }) => a == b,
840        (PathFrame::Step { step_id: a }, PathFrame::Step { step_id: b }) => a == b,
841        (
842            PathFrame::Call {
843                step_id: a,
844                callee_flow_id: af,
845                ..
846            },
847            PathFrame::Call {
848                step_id: b,
849                callee_flow_id: bf,
850                ..
851            },
852        ) => a == b && af == bf,
853        (
854            PathFrame::Iteration { index: a, key: ak },
855            PathFrame::Iteration { index: b, key: bk },
856        ) => a == b && ak == bk,
857        (a, b) => a == b,
858    }
859}
860
861/// Merges a judgment's localized manifest into a record's evidence:
862/// dedup key (sha256, asset.id), first occurrence wins (item ③ — one
863/// rule for checkpoint and dossier).
864fn merge_evidence(evidence: &mut Vec<EvidenceRef>, localized: &[EvidenceRef]) {
865    for entry in localized {
866        let duplicate = evidence
867            .iter()
868            .any(|existing| existing.sha256 == entry.sha256 && existing.asset.id == entry.asset.id);
869        if !duplicate {
870            evidence.push(entry.clone());
871        }
872    }
873}
874
875/// Projects a four-way terminal into the durable [`AttemptRecord`]
876/// (spine §6.6): discriminant + best-effort classification. The full
877/// outcome stays in the `actionSettled` payload.
878fn attempt_record(
879    call_id: &str,
880    outcome: &ActionOutcome,
881    dispatch: DispatchIdentity,
882) -> AttemptRecord {
883    let kind = match outcome {
884        ActionOutcome::Succeeded { .. } => ActionOutcomeKind::Succeeded,
885        ActionOutcome::Failed { .. } => ActionOutcomeKind::Failed,
886        ActionOutcome::Cancelled { .. } => ActionOutcomeKind::Cancelled,
887        ActionOutcome::TimedOut { .. } => ActionOutcomeKind::TimedOut,
888    };
889    // Best-effort M0 classification: ErrorInfo.code is an open string the
890    // provider adapter maps onto the closed ErrorClass; when the code
891    // already spells a class verbatim we adopt it, otherwise None (a
892    // dedicated carrier is M0-C).
893    let error_class = match outcome {
894        ActionOutcome::Succeeded { .. } => None,
895        ActionOutcome::Failed { error }
896        | ActionOutcome::Cancelled { error }
897        | ActionOutcome::TimedOut { error } => parse_error_class(&error.code),
898    };
899    let (execution_mode, fallback_reason) = match outcome {
900        ActionOutcome::Succeeded { result } => match &result.execution {
901            Some(ActionExecution::NativeSemantic { .. }) => {
902                (Some(ExecutionMode::NativeSemantic), None)
903            }
904            Some(ActionExecution::WebSemantic { .. }) => (Some(ExecutionMode::WebSemantic), None),
905            Some(ActionExecution::CoordinateFallback {
906                fallback_reason, ..
907            }) => (
908                Some(ExecutionMode::CoordinateFallback),
909                Some(*fallback_reason),
910            ),
911            None => (None, None),
912        },
913        _ => (None, None),
914    };
915    let (chain_index, channel, action_name) = dispatch;
916    AttemptRecord {
917        call_id: call_id.to_owned(),
918        outcome: kind,
919        error_class,
920        execution_mode,
921        fallback_reason,
922        chain_index,
923        channel,
924        action_name,
925    }
926}
927
928fn parse_error_class(code: &str) -> Option<ErrorClass> {
929    serde_json::from_value(Value::String(code.to_owned())).ok()
930}
931
932/// Projects a folded [`Verdict`] onto the durable per-step [`StepVerdict`]
933/// (spine §6.6: summary/evidence stay in the `verdictRecorded` payload).
934fn project_verdict(verdict: &Verdict) -> StepVerdict {
935    StepVerdict {
936        status: verdict.status,
937        degraded: verdict.degraded,
938        supersedes: verdict.supersedes.clone(),
939    }
940}
941
942#[cfg(test)]
943mod tests {
944    use pointlock_ir::{BindingState, EventCursor, SupervisePolicy};
945    use serde_json::json;
946
947    use super::*;
948
949    fn hash(fill: char) -> Hash {
950        Hash::new(format!("sha256:{}", fill.to_string().repeat(64))).expect("valid hash")
951    }
952
953    fn meta() -> RunMeta {
954        RunMeta {
955            run_id: "run-1".to_owned(),
956            flow_id: FlowId::new("checkout").expect("valid flow id"),
957            ir_hash: hash('a'),
958            lockfile_digest: hash('b'),
959            params_snapshot: json!({"user": "alice"}),
960            binding: BindingState {
961                device_id: "dev-1".to_owned(),
962                session_lineage: vec!["s-1".to_owned()],
963                event_cursor: EventCursor {
964                    session_id: "s-1".to_owned(),
965                    last_sequence: 0,
966                },
967            },
968            created_at_ms: 1,
969        }
970    }
971
972    fn event(seq: u64, run_path: RunPath, payload: RunLogPayload) -> RunLogEvent {
973        RunLogEvent {
974            run_id: "run-1".to_owned(),
975            seq,
976            at_ms: 1_000 + seq,
977            run_path,
978            payload,
979        }
980    }
981
982    fn run_started() -> RunLogPayload {
983        RunLogPayload::RunStarted {
984            ir_hash: hash('a'),
985            lockfile_digest: hash('b'),
986            params_snapshot: json!({"user": "alice"}),
987            supervise_policy: Some(SupervisePolicy::Mutating),
988        }
989    }
990
991    #[test]
992    fn zero_events_fold_to_the_seeded_pre_start_view() {
993        let folded = fold_checkpoint(&meta(), &[]).expect("fold");
994        assert!(folded.view.frames.is_empty());
995        assert_eq!(folded.view.frontier.state, StepState::Pending);
996        assert_eq!(folded.status, RunStatus::Running);
997    }
998
999    #[test]
1000    fn run_started_initializes_the_root_frame_from_meta_flow_id() {
1001        let folded = fold_checkpoint(&meta(), &[event(1, vec![], run_started())]).expect("fold");
1002        assert_eq!(folded.view.frames.len(), 1);
1003        assert_eq!(folded.view.frames[0].flow_id.as_str(), "checkout");
1004        assert_eq!(
1005            folded.view.frames[0].inputs_snapshot,
1006            json!({"user": "alice"})
1007        );
1008        assert_eq!(folded.view.frames[0].next_index, 0);
1009    }
1010
1011    #[test]
1012    fn events_before_run_started_are_rejected() {
1013        let err = fold_checkpoint(
1014            &meta(),
1015            &[event(
1016                1,
1017                vec![],
1018                RunLogPayload::StepEntered {
1019                    step_id: StepId::new("login").expect("valid step id"),
1020                    effect_hash: hash('c'),
1021                    judge_hash: hash('d'),
1022                    resolved_inputs: json!({}),
1023                },
1024            )],
1025        )
1026        .expect_err("must reject");
1027        assert_eq!(
1028            err,
1029            FoldError::EventBeforeRunStarted {
1030                seq: 1,
1031                event_type: "stepEntered"
1032            }
1033        );
1034    }
1035
1036    #[test]
1037    fn duplicate_run_started_is_rejected() {
1038        let err = fold_checkpoint(
1039            &meta(),
1040            &[
1041                event(1, vec![], run_started()),
1042                event(2, vec![], run_started()),
1043            ],
1044        )
1045        .expect_err("must reject");
1046        assert_eq!(err, FoldError::DuplicateRunStarted { seq: 2 });
1047    }
1048
1049    #[test]
1050    fn non_monotonic_seq_is_rejected() {
1051        let err = fold_checkpoint(
1052            &meta(),
1053            &[
1054                event(1, vec![], run_started()),
1055                event(
1056                    1,
1057                    vec![],
1058                    RunLogPayload::RunSuspended {
1059                        provider_state_summary: None,
1060                        reason: None,
1061                    },
1062                ),
1063            ],
1064        )
1065        .expect_err("must reject");
1066        assert_eq!(err, FoldError::NonMonotonicSeq { prev: 1, seq: 1 });
1067    }
1068
1069    #[test]
1070    fn step_exited_without_entry_is_rejected() {
1071        let err = fold_checkpoint(
1072            &meta(),
1073            &[
1074                event(1, vec![], run_started()),
1075                event(
1076                    2,
1077                    vec![],
1078                    RunLogPayload::StepExited {
1079                        provider_state_summary: None,
1080                        state: StepState::Judged,
1081                        output: None,
1082                        localized: Vec::new(),
1083                        localization_gaps: Vec::new(),
1084                    },
1085                ),
1086            ],
1087        )
1088        .expect_err("must reject");
1089        assert_eq!(err, FoldError::StepExitedWithoutEntry { seq: 2 });
1090    }
1091
1092    #[test]
1093    fn step_record_fields_are_harvested_from_the_carrier_events() {
1094        let step_path: RunPath = vec![PathFrame::Step {
1095            step_id: StepId::new("login").expect("valid step id"),
1096        }];
1097        let folded = fold_checkpoint(
1098            &meta(),
1099            &[
1100                event(1, vec![], run_started()),
1101                event(
1102                    2,
1103                    step_path.clone(),
1104                    RunLogPayload::StepEntered {
1105                        step_id: StepId::new("login").expect("valid step id"),
1106                        effect_hash: hash('c'),
1107                        judge_hash: hash('d'),
1108                        resolved_inputs: json!({"element": {"identifier": "loginButton"}}),
1109                    },
1110                ),
1111                event(
1112                    3,
1113                    step_path.clone(),
1114                    RunLogPayload::StepExited {
1115                        provider_state_summary: None,
1116                        state: StepState::Judged,
1117                        output: Some(json!({"ok": true})),
1118                        localized: Vec::new(),
1119                        localization_gaps: Vec::new(),
1120                    },
1121                ),
1122            ],
1123        )
1124        .expect("fold");
1125        let record = &folded.view.completed[0];
1126        assert_eq!(record.effect_hash, hash('c'));
1127        assert_eq!(record.judge_hash, hash('d'));
1128        assert_eq!(
1129            record.resolved_inputs,
1130            json!({"element": {"identifier": "loginButton"}})
1131        );
1132        assert_eq!(record.output, Some(json!({"ok": true})));
1133    }
1134
1135    #[test]
1136    fn popping_the_root_frame_is_rejected() {
1137        let err = fold_checkpoint(
1138            &meta(),
1139            &[
1140                event(1, vec![], run_started()),
1141                event(2, vec![], RunLogPayload::CallFramePopped { outputs: None }),
1142            ],
1143        )
1144        .expect_err("must reject");
1145        assert_eq!(err, FoldError::PoppedRootFrame { seq: 2 });
1146    }
1147
1148    #[test]
1149    fn unpaired_human_response_is_rejected() {
1150        let err = fold_checkpoint(
1151            &meta(),
1152            &[
1153                event(1, vec![], run_started()),
1154                event(
1155                    2,
1156                    vec![],
1157                    RunLogPayload::HumanResponded {
1158                        request_id: "req-ghost".to_owned(),
1159                        purpose: pointlock_ir::HumanPurpose::Step,
1160                        response: json!({}),
1161                        actor: "cli:tester".to_owned(),
1162                    },
1163                ),
1164            ],
1165        )
1166        .expect_err("must reject");
1167        assert_eq!(
1168            err,
1169            FoldError::UnpairedHumanResponse {
1170                seq: 2,
1171                request_id: "req-ghost".to_owned()
1172            }
1173        );
1174    }
1175
1176    fn human_requested(request_id: &str, purpose: HumanPurpose) -> RunLogPayload {
1177        RunLogPayload::HumanRequested {
1178            request_id: request_id.to_owned(),
1179            purpose,
1180            mode: match purpose {
1181                HumanPurpose::Step => Some(pointlock_ir::HumanMode::Confirm),
1182                HumanPurpose::Supervision => None,
1183            },
1184            prompt: "Decide".to_owned(),
1185            presents: json!([]),
1186            decisions: None,
1187            output_schema: None,
1188            deadline_at_ms: match purpose {
1189                HumanPurpose::Step => Some(9_000),
1190                HumanPurpose::Supervision => None,
1191            },
1192        }
1193    }
1194
1195    fn step_entered(id: &str) -> RunLogPayload {
1196        RunLogPayload::StepEntered {
1197            step_id: StepId::new(id).expect("valid step id"),
1198            effect_hash: hash('c'),
1199            judge_hash: hash('d'),
1200            resolved_inputs: json!({}),
1201        }
1202    }
1203
1204    fn step_exited() -> RunLogPayload {
1205        RunLogPayload::StepExited {
1206            provider_state_summary: None,
1207            state: StepState::Judged,
1208            output: None,
1209            localized: Vec::new(),
1210            localization_gaps: Vec::new(),
1211        }
1212    }
1213
1214    fn run_suspended() -> RunLogPayload {
1215        RunLogPayload::RunSuspended {
1216            reason: None,
1217            provider_state_summary: None,
1218        }
1219    }
1220
1221    /// A crash-opened span whose step the repaired IR no longer reaches
1222    /// (renamed `x` -> `x2`) is never re-entered and never closed. The
1223    /// container's exit must pair with the CONTAINER's entry, not with
1224    /// whatever is on top of the in-flight stack — otherwise the orphan
1225    /// takes the container's state/output and the container never gets a
1226    /// record (so it re-executes on every later resume).
1227    #[test]
1228    fn a_container_exit_pairs_with_its_own_entry_over_an_orphaned_open_span() {
1229        let flow = PathFrame::Flow {
1230            flow_id: FlowId::new("checkout").expect("valid flow id"),
1231            ir_hash: hash('a'),
1232        };
1233        let step = |id: &str| PathFrame::Step {
1234            step_id: StepId::new(id).expect("valid step id"),
1235        };
1236        let container: RunPath = vec![flow.clone(), step("each")];
1237        let iteration = PathFrame::Iteration {
1238            index: 0,
1239            key: None,
1240        };
1241        let orphan: RunPath = vec![flow.clone(), step("each"), iteration.clone(), step("x")];
1242        let renamed: RunPath = vec![flow, step("each"), iteration, step("x2")];
1243        let events = [
1244            event(1, vec![], run_started()),
1245            // Crashed segment: container entered, body step x entered.
1246            event(2, container.clone(), step_entered("each")),
1247            event(3, orphan.clone(), step_entered("x")),
1248            // Resume under the repaired IR: `each` re-entered (its span
1249            // is consumed, no new stepEntered), x2 runs, `each` exits.
1250            event(4, renamed.clone(), step_entered("x2")),
1251            event(5, renamed.clone(), step_exited()),
1252            event(
1253                6,
1254                container.clone(),
1255                RunLogPayload::StepExited {
1256                    provider_state_summary: None,
1257                    state: StepState::Judged,
1258                    output: Some(json!({"count": 1})),
1259                    localized: Vec::new(),
1260                    localization_gaps: Vec::new(),
1261                },
1262            ),
1263        ];
1264        let folded = fold_checkpoint(&meta(), &events).expect("fold");
1265        let completed: Vec<(&str, &RunPath)> = folded
1266            .view
1267            .completed
1268            .iter()
1269            .map(|record| (record.step_id.as_str(), &record.run_path))
1270            .collect();
1271        assert_eq!(completed, vec![("x2", &renamed), ("each", &container)]);
1272        assert_eq!(
1273            folded.view.completed[1].output,
1274            Some(json!({"count": 1})),
1275            "the container's exit output lands on the container's record"
1276        );
1277        // The orphan is still open — nothing closed it.
1278        let state = fold_state(&meta(), &events).expect("fold state");
1279        assert_eq!(state.in_flight.len(), 1);
1280        assert_eq!(state.in_flight[0].run_path, orphan);
1281    }
1282
1283    /// An escalate hook's human is anchored UNDER the host step and
1284    /// settled in memory on lazy timeout — the only ledger trace is the
1285    /// host's exit. That exit must clear the request (06 §5.3), or a
1286    /// later suspension folds `awaitingHuman` for a dead request.
1287    #[test]
1288    fn a_host_step_exit_settles_the_hook_human_anchored_beneath_it() {
1289        let host: RunPath = vec![
1290            PathFrame::Flow {
1291                flow_id: FlowId::new("checkout").expect("valid flow id"),
1292                ir_hash: hash('a'),
1293            },
1294            PathFrame::Step {
1295                step_id: StepId::new("pay").expect("valid step id"),
1296            },
1297        ];
1298        let mut hook_human = host.clone();
1299        hook_human.push(PathFrame::Hook {
1300            hook: pointlock_ir::HandlerHook::OnFail,
1301            trigger: 1,
1302        });
1303        hook_human.push(PathFrame::Step {
1304            step_id: StepId::new("ask").expect("valid step id"),
1305        });
1306        let folded = fold_checkpoint(
1307            &meta(),
1308            &[
1309                event(1, vec![], run_started()),
1310                event(2, host.clone(), step_entered("pay")),
1311                event(3, hook_human, human_requested("req-1", HumanPurpose::Step)),
1312                event(4, host, step_exited()),
1313                event(5, vec![], run_suspended()),
1314            ],
1315        )
1316        .expect("fold");
1317        assert!(folded.view.human_pending.is_none());
1318        assert_eq!(folded.status, RunStatus::Suspended);
1319    }
1320
1321    /// A cross-IR resume exits the awaiting step at a path carrying the
1322    /// NEW flow hash; the request was recorded under the old one. Site
1323    /// identity, not hash identity, settles it.
1324    #[test]
1325    fn an_exit_under_the_new_ir_hash_settles_the_old_hash_request() {
1326        let path = |fill: char| -> RunPath {
1327            vec![
1328                PathFrame::Flow {
1329                    flow_id: FlowId::new("checkout").expect("valid flow id"),
1330                    ir_hash: hash(fill),
1331                },
1332                PathFrame::Step {
1333                    step_id: StepId::new("ask").expect("valid step id"),
1334                },
1335            ]
1336        };
1337        let folded = fold_checkpoint(
1338            &meta(),
1339            &[
1340                event(1, vec![], run_started()),
1341                event(2, path('a'), step_entered("ask")),
1342                event(3, path('a'), human_requested("req-1", HumanPurpose::Step)),
1343                event(4, path('e'), step_exited()),
1344            ],
1345        )
1346        .expect("fold");
1347        assert!(folded.view.human_pending.is_none());
1348    }
1349
1350    #[test]
1351    fn supervision_suspend_answer_keeps_the_request_pending() {
1352        let step_path: RunPath = vec![PathFrame::Step {
1353            step_id: StepId::new("pay").expect("valid step id"),
1354        }];
1355        let folded = fold_checkpoint(
1356            &meta(),
1357            &[
1358                event(1, vec![], run_started()),
1359                event(
1360                    2,
1361                    step_path.clone(),
1362                    human_requested("req-1", HumanPurpose::Supervision),
1363                ),
1364                event(
1365                    3,
1366                    step_path.clone(),
1367                    RunLogPayload::HumanResponded {
1368                        request_id: "req-1".to_owned(),
1369                        purpose: HumanPurpose::Supervision,
1370                        response: json!({"decision": "suspend"}),
1371                        actor: "cli:tester".to_owned(),
1372                    },
1373                ),
1374                // The suspend ruling parks the run; the request survives.
1375                event(
1376                    4,
1377                    vec![],
1378                    RunLogPayload::RunSuspended {
1379                        provider_state_summary: None,
1380                        reason: None,
1381                    },
1382                ),
1383            ],
1384        )
1385        .expect("fold");
1386        let pending = folded.view.human_pending.expect("request stays pending");
1387        assert_eq!(pending.request_id, "req-1");
1388        assert_eq!(folded.status, RunStatus::AwaitingHuman);
1389
1390        // A later final ruling still pairs and settles the wait.
1391        let folded = fold_checkpoint(
1392            &meta(),
1393            &[
1394                event(1, vec![], run_started()),
1395                event(
1396                    2,
1397                    step_path.clone(),
1398                    human_requested("req-1", HumanPurpose::Supervision),
1399                ),
1400                event(
1401                    3,
1402                    step_path.clone(),
1403                    RunLogPayload::HumanResponded {
1404                        request_id: "req-1".to_owned(),
1405                        purpose: HumanPurpose::Supervision,
1406                        response: json!({"decision": "suspend"}),
1407                        actor: "cli:tester".to_owned(),
1408                    },
1409                ),
1410                event(
1411                    4,
1412                    step_path,
1413                    RunLogPayload::HumanResponded {
1414                        request_id: "req-1".to_owned(),
1415                        purpose: HumanPurpose::Supervision,
1416                        response: json!({"decision": "proceed"}),
1417                        actor: "cli:tester".to_owned(),
1418                    },
1419                ),
1420            ],
1421        )
1422        .expect("fold");
1423        assert!(folded.view.human_pending.is_none());
1424        assert_eq!(folded.status, RunStatus::Running);
1425    }
1426
1427    #[test]
1428    fn run_suspended_while_a_request_is_pending_stays_awaiting_human() {
1429        let step_path: RunPath = vec![PathFrame::Step {
1430            step_id: StepId::new("ask").expect("valid step id"),
1431        }];
1432        let folded = fold_checkpoint(
1433            &meta(),
1434            &[
1435                event(1, vec![], run_started()),
1436                event(2, step_path, human_requested("req-2", HumanPurpose::Step)),
1437                event(
1438                    3,
1439                    vec![],
1440                    RunLogPayload::RunSuspended {
1441                        provider_state_summary: None,
1442                        reason: None,
1443                    },
1444                ),
1445            ],
1446        )
1447        .expect("fold");
1448        assert_eq!(folded.status, RunStatus::AwaitingHuman);
1449        let pending = folded.view.human_pending.expect("pending");
1450        assert_eq!(pending.deadline_at_ms, Some(9_000));
1451        assert_eq!(pending.mode, Some(pointlock_ir::HumanMode::Confirm));
1452    }
1453
1454    #[test]
1455    fn step_exit_settles_the_pending_request_without_a_response() {
1456        // The lazy timeout settlement shape: the awaiting step exits
1457        // (verdict unknown) with no humanResponded on the ledger.
1458        let step_path: RunPath = vec![PathFrame::Step {
1459            step_id: StepId::new("ask").expect("valid step id"),
1460        }];
1461        let folded = fold_checkpoint(
1462            &meta(),
1463            &[
1464                event(1, vec![], run_started()),
1465                event(
1466                    2,
1467                    step_path.clone(),
1468                    RunLogPayload::StepEntered {
1469                        step_id: StepId::new("ask").expect("valid step id"),
1470                        effect_hash: hash('c'),
1471                        judge_hash: hash('d'),
1472                        resolved_inputs: json!({"presents": []}),
1473                    },
1474                ),
1475                event(
1476                    3,
1477                    step_path.clone(),
1478                    human_requested("req-3", HumanPurpose::Step),
1479                ),
1480                event(
1481                    4,
1482                    vec![],
1483                    RunLogPayload::RunSuspended {
1484                        provider_state_summary: None,
1485                        reason: None,
1486                    },
1487                ),
1488                event(
1489                    5,
1490                    vec![],
1491                    RunLogPayload::RunResumed {
1492                        alignment_report: pointlock_ir::AlignmentReport {
1493                            entries: vec![],
1494                            resume_point: None,
1495                            requires_confirmation: vec![],
1496                        },
1497                        supervise_policy: None,
1498                        event_cursor: None,
1499                    },
1500                ),
1501                event(
1502                    6,
1503                    step_path,
1504                    RunLogPayload::StepExited {
1505                        provider_state_summary: None,
1506                        state: StepState::Judged,
1507                        output: None,
1508                        localized: Vec::new(),
1509                        localization_gaps: Vec::new(),
1510                    },
1511                ),
1512            ],
1513        )
1514        .expect("fold");
1515        assert!(folded.view.human_pending.is_none());
1516        assert_eq!(folded.status, RunStatus::Running);
1517    }
1518
1519    #[test]
1520    fn error_class_is_adopted_only_when_the_code_spells_a_class() {
1521        assert_eq!(
1522            parse_error_class("action_failed_final"),
1523            Some(ErrorClass::ActionFailedFinal)
1524        );
1525        assert_eq!(parse_error_class("SOME_DAEMON_CODE"), None);
1526    }
1527}