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}