Skip to main content

pointlock_runner/
runner.rs

1//! The public entry points: [`Runner::run`] and [`Runner::resume`] /
2//! [`Runner::resume_with_subflows`] (spine §6; 07 §4–§5).
3//!
4//! Since M2 the entry points accept the resolved subflow registry
5//! (`Map<irHash, FlowIR>`, provided by the assembly layer; every entry
6//! self-verifies at load). Resume comes in two regimes:
7//!
8//! - **Same-IR resume** (the common suspend/crash continuation): every
9//!   completed step *instance* is adopted by its exact run path — the
10//!   walk falls back into open call frames and foreach iterations without
11//!   restarting any frame (07 §4.6), with the archived control snapshots
12//!   (cond / items / inputs) never re-evaluated (I3).
13//! - **Cross-IR resume** (repair): the 07 §5.2 *flat* subset — alignment,
14//!   offline re-judge and the `requiresConfirmation` gates over top-level
15//!   action steps. The nested rules (call down-drill, per-iteration
16//!   alignment, order-consistency) land with the repair wave; the
17//!   combination is refused with a typed error, never silently guessed.
18
19use std::collections::{BTreeMap, BTreeSet};
20
21use pointlock_ir::{
22    ActionStepIR, AlignmentClass, AlignmentEntry, AlignmentReport, BindingState, CheckpointView,
23    FlowIR, Hash, PathFrame, ReconcileResult, RequiresConfirmation, RunLogPayload, RunPath,
24    SupervisePolicy, ir_hash,
25};
26use pointlock_provider_kit::{CancellationToken, ProviderSession, SessionOutcome};
27use pointlock_store::{NewRun, Store, WriterLease};
28use serde_json::{Map, Value};
29
30use crate::align::{Alignment, Harvest, align, harvest, live_frame_pins, open_instances};
31use crate::engine::{
32    Adopted, Execution, FrameState, FrontierWork, HumanRequestFact, RunOutcome, gated_mutating,
33    instance_key, is_history, now_ms, params_with_defaults, replay_permitted, root_path,
34};
35use crate::error::{BlockedReason, RunnerError};
36use crate::load::{LoadedFlow, check_attestation, load};
37use crate::scope::ScopeSeed;
38
39/// Options of [`Runner::run`].
40pub struct RunOptions {
41    /// Cooperative stop token, honored at step boundaries
42    /// (`runSuspended` → [`RunOutcome::Suspended`]).
43    pub stop: CancellationToken,
44    /// Explicit run id; a UUIDv4 is generated when absent.
45    pub run_id: Option<String>,
46    /// The bound device (checkpoint hard binding; also `env.deviceId`).
47    pub device_id: String,
48    /// The device platform for `env.platform`, when known (comes from the
49    /// lockfile at the assembly layer; the SPI attestation does not carry
50    /// it).
51    pub platform: Option<String>,
52    /// The vision verifier consulted by `vision` verify-chain tails.
53    /// `None` is equivalent to
54    /// [`pointlock_vision::StubVisionVerifier`]: the vision channel cannot
55    /// complete and reports `"vision verifier not configured"` — the chain
56    /// degrades honestly toward `unknown` (principle 4).
57    pub vision: Option<std::sync::Arc<dyn pointlock_vision::VisionVerifier>>,
58    /// The resolved subflow registry keyed by `irHash` (07 §1.3): every
59    /// callee the flow's `subflows` table pins must be present; entries
60    /// self-verify at load. Empty for flows without subflows.
61    pub subflows: BTreeMap<Hash, FlowIR>,
62    /// This segment's supervision policy (R13, spine §6.9): recorded in
63    /// `runStarted.supervisePolicy` (explicitly `null` when absent) and
64    /// gates action-step dispatch (`mutating` gates mutating steps,
65    /// `all` every action step). Per segment, never inherited.
66    pub supervise: Option<SupervisePolicy>,
67    /// Injectable wall clock for human-deadline computation and lazy
68    /// timeout settlement (tests); `None` uses the system clock.
69    pub clock: Option<std::sync::Arc<dyn Fn() -> u64 + Send + Sync>>,
70    /// Breakpoint: suspend BEFORE entering the first step instance that
71    /// matches this target (`pointlock run --stop-at`). A target is a run
72    /// path — canonical (`flow@hash8/each[1]/tap`) or relative to the
73    /// root flow (`each[1]/tap`) — or a bare step id, meaning the first
74    /// instance whose step id matches. Per invocation, never persisted:
75    /// the suspension is recorded honestly (`runSuspended.reason =
76    /// "stopped at breakpoint --stop-at <canonical path>"`) and a later
77    /// resume continues past it unless the option is given again. A
78    /// target that never matches is not an error. Only a genuine entry
79    /// matches: a container whose span the previous segment left open
80    /// (the run was suspended inside it) is continued on resume, not
81    /// entered — its `stepEntered` is already on the ledger — so naming
82    /// it as the target of the resuming segment does not stop there.
83    pub stop_at: Option<String>,
84    /// Breakpoint: suspend AFTER the exit of the first matching step
85    /// instance (`--stop-after`); same target grammar and posture as
86    /// [`Self::stop_at`], reason `"stopped at breakpoint --stop-after
87    /// <canonical path>"`.
88    pub stop_after: Option<String>,
89}
90
91impl RunOptions {
92    /// Options with a fresh stop token, no explicit run id, no vision
93    /// verifier (stub-equivalent), an empty subflow registry, no
94    /// supervision and the system clock.
95    pub fn new(device_id: impl Into<String>) -> Self {
96        RunOptions {
97            stop: CancellationToken::new(),
98            run_id: None,
99            device_id: device_id.into(),
100            platform: None,
101            vision: None,
102            subflows: BTreeMap::new(),
103            supervise: None,
104            clock: None,
105            stop_at: None,
106            stop_after: None,
107        }
108    }
109}
110
111/// Options of [`Runner::resume`].
112#[derive(Default)]
113pub struct ResumeOptions {
114    /// Cooperative stop token (see [`RunOptions::stop`]).
115    pub stop: CancellationToken,
116    /// Breakpoint before a step instance's entry (see
117    /// [`RunOptions::stop_at`]); per segment, never persisted.
118    pub stop_at: Option<String>,
119    /// Breakpoint after a step instance's exit (see
120    /// [`RunOptions::stop_after`]); per segment, never persisted.
121    pub stop_after: Option<String>,
122    /// `env.platform`, when known.
123    pub platform: Option<String>,
124    /// The FlowIR the run originally executed — optional, and worth
125    /// supplying. Alignment reads the archived execution-time per-step
126    /// hashes from the checkpoint's `StepRecord`s (harvested from
127    /// `stepEntered`, spine §6.1 M1 note), so cross-IR resume works
128    /// without it; supplying it additionally unlocks the preflight-only
129    /// sub-domain comparison (07 §5.3 / 02 §12.3 ruling 6) — a
130    /// `judgeDirty` step whose only change is `preflight` adopts its
131    /// archived verdict outright instead of re-judging or re-executing.
132    /// Verified against the checkpoint's `irHash`
133    /// ([`RunnerError::OldIrMismatch`]); a mismatch is a caller error,
134    /// surfaced not ignored.
135    pub old_flow_ir: Option<FlowIR>,
136    /// This segment's supervision policy (R13, spine §6.9): recorded in
137    /// `runResumed.supervisePolicy` (explicitly `null` when absent).
138    /// Per segment, never inherited — an unset value means this segment
139    /// runs unsupervised regardless of previous segments; a supervision
140    /// request already pending still settles by its arbitrated response.
141    pub supervise: Option<SupervisePolicy>,
142    /// The vision verifier of this segment (see [`RunOptions::vision`]);
143    /// `None` is stub-equivalent — vision tails degrade to `unknown`.
144    pub vision: Option<std::sync::Arc<dyn pointlock_vision::VisionVerifier>>,
145    /// Step ids the author FORCES back to execution this segment
146    /// (07 §5.3, the CLI's repeatable `--force-reexecute <stepId>`):
147    /// each named step classifies `effectDirty` regardless of its hashes,
148    /// so the resume point rolls back to the earliest of them and they
149    /// re-run against the live world.
150    ///
151    /// The escape hatch for a re-judge the author rejects — an offline
152    /// re-judge that can only reach `unknown` because the archive lacks
153    /// the observation channel the new assertion needs (「缺料 →
154    /// unknown」), or an adopted result the author no longer trusts. It
155    /// upgrades the CLASSIFICATION only: a forced step that is mutating
156    /// and already effective still walks the 07 §5.4 gate and needs
157    /// `--allow-mutating-reexec` besides — forcing says "run it again",
158    /// authorizing says "yes, even though the world holds its effect".
159    /// Like the authorization list it covers this resume only, and it is
160    /// cross-IR vocabulary: a same-IR resume has no classification to
161    /// upgrade.
162    pub force_reexecute: Vec<String>,
163    /// Skip the per-run writer lease (07 §3.3 rule 5) for this segment —
164    /// the CLI's `--force-stale-writer`: the escape hatch for filesystems
165    /// where `flock` lies (NFS/SMB). The segment then writes without any
166    /// liveness claim; the caller vouches that no other writer is alive.
167    pub force_stale_writer: bool,
168    /// Step ids the author explicitly authorized for mutating
169    /// re-execution this segment (07 §5.4 step 2, the CLI's repeatable
170    /// `--allow-mutating-reexec <stepId>`).
171    ///
172    /// Each id releases exactly one `requiresConfirmation` entry; there is
173    /// no wildcard, and the authorization covers **this resume only** —
174    /// nothing about it is persisted, so the next resume re-gates from
175    /// scratch. An id naming no gated step is refused rather than ignored:
176    /// silently accepting it would let an author believe they had cleared
177    /// something they had not.
178    ///
179    /// Releasing the gate does not skip the world check: the step still
180    /// enters `probing` and evaluates its `preflight` (§5.4 step 3), which
181    /// is what meets the residue of the earlier effect.
182    pub allow_mutating_reexec: Vec<String>,
183    /// Injectable wall clock (see [`RunOptions::clock`]).
184    pub clock: Option<std::sync::Arc<dyn Fn() -> u64 + Send + Sync>>,
185}
186
187/// The runner: executes a sealed [`FlowIR`] against an open provider
188/// session, journaling every transition into the single-writer store.
189/// Entry signatures accept only `FlowIR`, never strings (principles 1/2).
190pub struct Runner;
191
192impl Runner {
193    /// Runs a flow from the beginning (spine §6.2 pipeline; §6.1 event
194    /// vocabulary). This segment's `supervisePolicy` is recorded verbatim
195    /// in `runStarted` — explicitly `null` when unsupervised (R13,
196    /// per-segment self-describing ledger).
197    pub async fn run(
198        flow: &FlowIR,
199        params: Value,
200        session: Box<dyn ProviderSession>,
201        store: &mut Store,
202        opts: RunOptions,
203    ) -> Result<RunOutcome, RunnerError> {
204        let RunOptions {
205            stop,
206            run_id,
207            device_id,
208            platform,
209            vision,
210            subflows,
211            supervise,
212            clock,
213            stop_at,
214            stop_after,
215        } = opts;
216        let loaded = load(flow, &subflows)?;
217        check_attestation(&loaded, session.attestation())?;
218        let params = params_with_defaults(flow, params)?;
219
220        let cursor = session.current_cursor().await?;
221        let initial_lineage = vec![cursor.session_id.clone()];
222        // The writer lease precedes the first append and lives on this
223        // stack for the whole segment (07 §3.3 rule 5): every return path
224        // below — error or outcome — releases it on unwind.
225        let run_id = run_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
226        let _lease = WriterLease::acquire(store.root(), &run_id)?;
227        let run_id = store.begin_run(NewRun {
228            run_id: Some(run_id),
229            flow_id: flow.flow_id.clone(),
230            ir_hash: flow.ir_hash.clone(),
231            lockfile_digest: flow.lockfile_digest.clone(),
232            params_snapshot: Value::Object(params.clone()),
233            binding: BindingState {
234                device_id: device_id.clone(),
235                session_lineage: vec![cursor.session_id.clone()],
236                event_cursor: cursor,
237            },
238            created_at_ms: now_ms(),
239        })?;
240        store.append_event(
241            &run_id,
242            now_ms(),
243            &root_path(flow),
244            &RunLogPayload::RunStarted {
245                ir_hash: flow.ir_hash.clone(),
246                lockfile_digest: flow.lockfile_digest.clone(),
247                params_snapshot: Value::Object(params.clone()),
248                // R13: this segment's real policy — explicitly null when
249                // unsupervised (per-segment, self-describing).
250                supervise_policy: supervise,
251            },
252        )?;
253
254        let env = env_bindings(&device_id, platform.as_deref(), &run_id);
255        let exec = Execution {
256            flows: &loaded,
257            session,
258            store,
259            run_id,
260            stop,
261            stop_at,
262            stop_after,
263            env,
264            session_lineage: initial_lineage,
265            pending_summaries: Default::default(),
266            attempt_base: Default::default(),
267            open_spans: Default::default(),
268            live_frames: Default::default(),
269            // A fresh run never re-touches a world it stopped watching.
270            resumed: false,
271            authorized: BTreeSet::new(),
272            reentry_seen: false,
273            adoptable: Default::default(),
274            frontier: None,
275            vision,
276            supervise,
277            human: Default::default(),
278            settled: Default::default(),
279            recorded_verdicts: Default::default(),
280            hook_triggers: Default::default(),
281            clock,
282        };
283        let root = FrameState::new(flow, root_path(flow), params, 1);
284        exec.run(root, 0).await
285    }
286
287    /// Resumes a run (07 §4) without subflows. Legality ⟺ (A) every
288    /// completed record is still recognized under the (possibly repaired)
289    /// new IR — recorded as `alignmentReport` in `runResumed`; (B) a
290    /// pending intent on the frontier has been reconciled
291    /// (`ProviderSession::reconcile`); (C) the world passes the resume
292    /// probes — the first to-execute step's declared `preflight` runs
293    /// before its act; a step without probes resumes honestly `unprobed`
294    /// (I3).
295    pub async fn resume(
296        new_flow: &FlowIR,
297        run_id: &str,
298        session: Box<dyn ProviderSession>,
299        store: &mut Store,
300        opts: ResumeOptions,
301    ) -> Result<RunOutcome, RunnerError> {
302        let subflows = BTreeMap::new();
303        Self::resume_with_subflows(new_flow, &subflows, run_id, session, store, opts).await
304    }
305
306    /// [`Runner::resume`] with a resolved subflow registry — required when
307    /// the (new) flow pins callees; see [`RunOptions::subflows`].
308    pub async fn resume_with_subflows(
309        new_flow: &FlowIR,
310        subflows: &BTreeMap<Hash, FlowIR>,
311        run_id: &str,
312        session: Box<dyn ProviderSession>,
313        store: &mut Store,
314        opts: ResumeOptions,
315    ) -> Result<RunOutcome, RunnerError> {
316        let loaded = load(new_flow, subflows)?;
317        check_attestation(&loaded, session.attestation())?;
318        let view = store.rebuild_checkpoint(run_id)?;
319        let events = store.events(run_id)?;
320        let facts = harvest(&events);
321        let executing = executing_ir_hash(&facts, &view);
322
323        // The optional old-IR integrity check: when the caller supplies
324        // one, verify it is the IR the run executed — the LAST segment's
325        // IR, not the bind-time one (a mismatched old IR is a caller
326        // error, surfaced not ignored).
327        if let Some(old) = opts.old_flow_ir.as_ref() {
328            let computed = ir_hash(old);
329            if computed != *executing {
330                return Err(RunnerError::OldIrMismatch {
331                    expected: executing.clone(),
332                    computed,
333                });
334            }
335        }
336
337        // Writer liveness (07 §3.3 rule 5): the lease is taken before this
338        // segment's first append (`runResumed`) and held on this stack
339        // until the segment returns, on every path. A held lease means a
340        // live writer → `WriterBusy`; a free lease under a `running`
341        // status is a crash residue and resumes normally.
342        let _lease = if opts.force_stale_writer {
343            None
344        } else {
345            Some(WriterLease::acquire(store.root(), run_id)?)
346        };
347
348        if is_same_ir_resume(&facts, &view, new_flow) {
349            resume_same_ir(loaded, view, facts, run_id, session, store, opts).await
350        } else {
351            resume_cross_ir(loaded, view, facts, run_id, session, store, opts).await
352        }
353    }
354
355    /// The READ-ONLY alignment preview (08 §2.7): the resume path's
356    /// classification verbatim — same-IR trivial adoption or the flat
357    /// cross-IR `align` — but no session, no attestation, no writes, no
358    /// commitment. The preview is not a promise: the world can drift
359    /// between preview and resume; the resume-time preflight probes stay
360    /// the final judge. A confirmation-gated alignment is a preview
361    /// RESULT here (the report shows what the real resume would refuse),
362    /// not an error.
363    #[allow(clippy::too_many_arguments)]
364    pub async fn align_preview(
365        new_flow: &FlowIR,
366        subflows: &BTreeMap<Hash, FlowIR>,
367        run_id: &str,
368        store: &Store,
369        platform: Option<&str>,
370        vision: Option<&dyn pointlock_vision::VisionVerifier>,
371        forced: &[String],
372        old_flow_ir: Option<&FlowIR>,
373    ) -> Result<AlignmentReport, RunnerError> {
374        let loaded = load(new_flow, subflows)?;
375        let view = store.rebuild_checkpoint(run_id)?;
376        let events = store.events(run_id)?;
377        let facts = harvest(&events);
378        let executing = executing_ir_hash(&facts, &view);
379
380        // The same old-IR integrity check the real resume applies: a
381        // mismatched old IR is a caller error, and rehearsing with it
382        // would classify against the wrong sub-domains.
383        if let Some(old) = old_flow_ir {
384            let computed = ir_hash(old);
385            if computed != *executing {
386                return Err(RunnerError::OldIrMismatch {
387                    expected: executing.clone(),
388                    computed,
389                });
390            }
391        }
392
393        // The preview mirrors resume's typed refusals — a clean rehearsal
394        // of a resume the runner would categorically refuse is a lie.
395        if let Some(live_hook) = facts
396            .live_frames
397            .iter()
398            .find(|path| path.iter().any(|f| matches!(f, PathFrame::Hook { .. })))
399        {
400            return Err(RunnerError::M0Unsupported {
401                detail: format!(
402                    "resume across a live handler-repair frame ({}) is not in the M2 subset — \
403                     the repair flow suspended mid-flight; hook-aware frame re-entry is \
404                     registered for the repair wave",
405                    pointlock_ir::render_run_path(live_hook)
406                ),
407            });
408        }
409
410        if is_same_ir_resume(&facts, &view, new_flow) {
411            return Ok(same_ir_report(new_flow, &facts, &view));
412        }
413
414        if !is_alignable_path(&view.frontier.run_path) {
415            return Err(RunnerError::M0Unsupported {
416                detail: "the run's frontier sits inside a handler frame".to_owned(),
417            });
418        }
419        // Mirrors resume_cross_ir's third hook state (an escalate human
420        // still awaiting an answer): rehearsing a resume the runner would
421        // categorically refuse is a lie.
422        if view
423            .human_pending
424            .as_ref()
425            .is_some_and(|pending| !is_alignable_path(&pending.run_path))
426        {
427            return Err(RunnerError::M0Unsupported {
428                detail: "a handler escalation is still awaiting an answer".to_owned(),
429            });
430        }
431
432        // `env.platform` comes from the caller (the serve endpoint reads
433        // it from the SAME lockfile the resume assembly uses); when
434        // absent, an expr predicate referencing it re-judges to unknown
435        // in the preview (fail-closed) while the real resume would judge
436        // it — pass the platform to keep the rehearsal faithful.
437        let seed = ScopeSeed::new(
438            params_object(&view),
439            &view.binding.device_id,
440            platform,
441            run_id,
442        );
443        match align(
444            &loaded,
445            &new_flow.body,
446            &view,
447            &facts,
448            &seed,
449            store,
450            vision,
451            // A preview shows what WOULD gate: it authorizes nothing. The
452            // FORCED list and the old IR it does take — the rehearsal must
453            // classify exactly as the real resume will.
454            &[],
455            forced,
456            old_flow_ir,
457        )
458        .await
459        {
460            Ok(alignment) => Ok(alignment.report),
461            Err(RunnerError::RequiresConfirmation { report }) => Ok(*report),
462            Err(other) => Err(other),
463        }
464    }
465}
466
467/// `env.*` bindings: `deviceId`, `runId`, and `platform` when known (the
468/// platform comes from the assembly layer — the SPI attestation does not
469/// carry it). Run-constant, read-only pass-through across frames (07 §1.2).
470fn env_bindings(device_id: &str, platform: Option<&str>, run_id: &str) -> Vec<(String, Value)> {
471    let mut env = vec![
472        ("deviceId".to_owned(), Value::String(device_id.to_owned())),
473        ("runId".to_owned(), Value::String(run_id.to_owned())),
474    ];
475    if let Some(platform) = platform {
476        env.push(("platform".to_owned(), Value::String(platform.to_owned())));
477    }
478    env
479}
480
481/// The IR the run is currently executing under: the last `runResumed`
482/// segment's root flow hash when one exists, else the bind-time run-row
483/// hash. The fold never rebases `view.ir_hash` past `runStarted`, so after
484/// a cross-IR segment the checkpoint hash names the ORIGINAL artifact —
485/// the `--old-ir` check must name the last executing IR instead.
486fn executing_ir_hash<'a>(facts: &'a Harvest, view: &'a CheckpointView) -> &'a Hash {
487    facts.executing_ir_hash.as_ref().unwrap_or(&view.ir_hash)
488}
489
490/// Whether the trivial same-IR adoption is sound: the new IR is the one
491/// the run was bound to AND every segment so far executed under it. A
492/// ledger that has executed under another IR holds records of more than
493/// one IR, and adopting them by instance key alone would either credit
494/// the original artifact with a repaired segment's results or adopt
495/// positionally invalidated first-life records (07 §5.2) — such a resume
496/// is classified by `align` whatever IR it names.
497fn is_same_ir_resume(facts: &Harvest, view: &CheckpointView, new_flow: &FlowIR) -> bool {
498    !facts.cross_ir_resumed && view.ir_hash == new_flow.ir_hash
499}
500
501/// The same-IR alignment report: top-level instances with execution
502/// history are trivially reusable (identical hashes by construction);
503/// the rest re-execute as `new`. Shared by [`resume_same_ir`] and the
504/// read-only [`Runner::align_preview`] — one classification truth source,
505/// so the liveness rule ([`open_instances`]) is applied here too (R13).
506fn same_ir_report(new_flow: &FlowIR, facts: &Harvest, view: &CheckpointView) -> AlignmentReport {
507    let open = open_instances(facts);
508    let completed: BTreeMap<String, &pointlock_ir::StepRecord> = view
509        .completed
510        .iter()
511        .filter(|record| !open.contains(&instance_key(&record.run_path)))
512        .map(|record| (instance_key(&record.run_path), record))
513        .collect();
514    let mut entries = Vec::new();
515    for step in &new_flow.body {
516        let mut path = root_path(new_flow);
517        path.push(match step {
518            pointlock_ir::StepIR::Call(call) => PathFrame::Call {
519                step_id: Some(call.base.step_id.clone()),
520                callee_flow_id: call.flow_ref.flow_id.clone(),
521                callee_ir_hash: call.flow_ref.ir_hash.clone(),
522            },
523            other => PathFrame::Step {
524                step_id: other.step_id().clone(),
525            },
526        });
527        let key = instance_key(&path);
528        let adopted = completed.get(&key).is_some_and(|record| is_history(record));
529        entries.push(AlignmentEntry {
530            run_path: path.clone(),
531            step_id: step.step_id().clone(),
532            class: if adopted {
533                AlignmentClass::Reusable
534            } else {
535                AlignmentClass::New
536            },
537            reason: (!adopted).then(|| "no adoptable prior record".to_owned()),
538        });
539    }
540    AlignmentReport {
541        entries,
542        resume_point: Some(view.frontier.run_path.clone()),
543        requires_confirmation: Vec::new(),
544    }
545}
546
547// ─── same-IR resume: frame-precise adoption (07 §4.6) ───────────────────────
548
549/// Resumes under the identical IR: every completed step instance is
550/// adopted by its exact run path; open spans and live call frames are
551/// re-entered without re-appending their events; the walk lands on the
552/// frontier position inside any depth of nesting — no frame restarts, no
553/// snapshot re-evaluation.
554async fn resume_same_ir(
555    loaded: LoadedFlow<'_>,
556    view: CheckpointView,
557    facts: Harvest,
558    run_id: &str,
559    mut session: Box<dyn ProviderSession>,
560    store: &mut Store,
561    opts: ResumeOptions,
562) -> Result<RunOutcome, RunnerError> {
563    let new_flow = loaded.root;
564    // The bind-time binding cursor (run-row meta, written once at
565    // begin_run, never rewritten): the issuing credential of intents
566    // dispatched before any resume (07 §4.5).
567    let bind_cursor = store.run_meta(run_id)?.binding.event_cursor;
568    // Adoption set: completed instances keyed by their instance path.
569    // Liveness wins (07 §5.2): an instance left with an open span was
570    // re-executed after its record was written and suspended inside that
571    // re-execution; the open span (and its frontier work) is the live
572    // state, so the stale record is never adoptable.
573    let open = open_instances(&facts);
574    let mut adoptable: BTreeMap<String, Adopted> = BTreeMap::new();
575    for record in &view.completed {
576        let key = instance_key(&record.run_path);
577        if open.contains(&key) {
578            continue;
579        }
580        adoptable.insert(
581            key.clone(),
582            Adopted {
583                record: record.clone(),
584                before_id: facts.before_observation.get(&key).cloned(),
585                after_id: facts.after_observation.get(&key).cloned(),
586            },
587        );
588    }
589    let open_spans: BTreeMap<String, Value> = facts
590        .open_spans
591        .iter()
592        .map(|path| {
593            let key = instance_key(path);
594            let inputs = facts
595                .entered_inputs
596                .get(&key)
597                .cloned()
598                .unwrap_or(Value::Null);
599            (key, inputs)
600        })
601        .collect();
602    // A live hook-launched repair frame (a suspension *inside* a repair
603    // subflow) needs hook-aware frame re-entry — a typed M2 refusal, never
604    // a guess (the repair's own records stay archived and honest).
605    if let Some(live_hook) = facts
606        .live_frames
607        .iter()
608        .find(|path| path.iter().any(|f| matches!(f, PathFrame::Hook { .. })))
609    {
610        return Err(RunnerError::M0Unsupported {
611            detail: format!(
612                "resume across a live handler-repair frame ({}) is not in the M2 subset — \
613                 the repair flow suspended mid-flight; hook-aware frame re-entry is \
614                 registered for the repair wave",
615                pointlock_ir::render_run_path(live_hook)
616            ),
617        });
618    }
619    let live_frames = live_frame_pins(&facts);
620
621    // The alignment report of a same-IR resume (shared with the
622    // read-only preview — one classification truth source).
623    let mut report = same_ir_report(new_flow, &facts, &view);
624
625    // (B) unconditional reconcile of a pending intent (07 §4.1/§4.4). The
626    // frontier step is where the walk will land (everything before it is
627    // adopted), so `at_resume` holds by construction.
628    let mut frontier_work = None;
629    let mut deferred_settle = None;
630    let mut pending_adjudication: Option<Box<Adjudication>> = None;
631    let mut blocked = None;
632    if let Some(intent) = &view.frontier.pending_intent {
633        let frontier_key = instance_key(&view.frontier.run_path);
634        let new_step = loaded.resolve_action(&view.frontier.run_path);
635        // Same-IR: the archived entered hash matches the resolved step's
636        // by construction; a missing carrier fails closed (dirty).
637        let effect_dirty = match (new_step, facts.entered_effect_hash.get(&frontier_key)) {
638            (Some(step), Some(archived)) => *archived != step.base.effect_hash,
639            _ => true,
640        };
641        let decision = match reconcile_frontier(
642            &mut session,
643            new_step,
644            true,
645            effect_dirty,
646            &view,
647            &facts,
648            &bind_cursor,
649            &mut report,
650            intent,
651        )
652        .await
653        {
654            Ok(decision) => decision,
655            Err(error) => {
656                let _ = session.end(SessionOutcome::Shutdown, None).await;
657                return Err(error);
658            }
659        };
660        match decision {
661            FrontierDecision::Work(work) => frontier_work = Some((frontier_key, work)),
662            FrontierDecision::DeferredSettle(settle) => deferred_settle = Some(settle),
663            FrontierDecision::Adjudicate(adjudication) => pending_adjudication = Some(adjudication),
664            FrontierDecision::Blocked(reason) => blocked = Some(reason),
665            FrontierDecision::Nothing => {}
666        }
667    }
668
669    // The segment header: runResumed carries the alignment report, this
670    // segment's supervisePolicy (explicitly null when unsupervised —
671    // R13), and the new generation's reseeded cursor (07 §4.5: taken
672    // after the reconcile decisions, before this append; absent when the
673    // RPC fails — honest, never stale).
674    let resumed_cursor = session.current_cursor().await.ok();
675    store.append_event(
676        run_id,
677        now_ms(),
678        &root_path(new_flow),
679        &RunLogPayload::RunResumed {
680            alignment_report: report.clone(),
681            supervise_policy: opts.supervise,
682            event_cursor: resumed_cursor,
683        },
684    )?;
685
686    // A reconciled completed terminal that cannot be adopted at the
687    // resume point is still recorded — the ledger closes the intent and
688    // keeps the world fact as evidence (07 §4.1).
689    if let Some((path, call_id, outcome)) = deferred_settle {
690        store.append_event(
691            run_id,
692            now_ms(),
693            &path,
694            &RunLogPayload::ActionSettled {
695                call_id,
696                outcome: crate::engine::quarantine_unpersistable(*outcome),
697            },
698        )?;
699    }
700
701    if let Some(adjudication) = pending_adjudication {
702        // Phase 1 of the 07 §4.4 default escalation: the request (fresh or
703        // re-awaited) is the segment's outcome — the run suspends
704        // `awaitingHuman` and the answer arrives through the ordinary
705        // arbitration channel, durable for the next resume to consume.
706        let Adjudication {
707            run_path,
708            request,
709            pending,
710        } = *adjudication;
711        if let Some((request_id, prompt, presents)) = request {
712            store.append_event(
713                run_id,
714                now_ms(),
715                &run_path,
716                &RunLogPayload::HumanRequested {
717                    request_id,
718                    purpose: pointlock_ir::HumanPurpose::Step,
719                    mode: Some(pointlock_ir::vocab::HumanMode::RepairWorld),
720                    prompt,
721                    presents,
722                    decisions: Some(vec![
723                        "adopt".to_owned(),
724                        "redo".to_owned(),
725                        "abort".to_owned(),
726                    ]),
727                    output_schema: None,
728                    deadline_at_ms: None,
729                },
730            )?;
731        }
732        let summary = crate::engine::capture_provider_state_summary(
733            session.as_ref(),
734            &view.binding.session_lineage,
735            &view.binding.device_id,
736            opts.platform.as_deref(),
737        )
738        .await;
739        store.append_event(
740            run_id,
741            now_ms(),
742            &root_path(new_flow),
743            &RunLogPayload::RunSuspended {
744                provider_state_summary: Some(summary),
745                reason: Some(format!(
746                    "awaiting human adjudication (requestId {})",
747                    pending.request_id
748                )),
749            },
750        )?;
751        let _ = session.end(SessionOutcome::Shutdown, None).await;
752        return Ok(RunOutcome::AwaitingHuman { pending });
753    }
754
755    if let Some(reason) = blocked {
756        // Suspension-instant profile (07 §2.2): the session is still
757        // live at this pre-Execution blocked refusal.
758        let summary = crate::engine::capture_provider_state_summary(
759            session.as_ref(),
760            &view.binding.session_lineage,
761            &view.binding.device_id,
762            opts.platform.as_deref(),
763        )
764        .await;
765        store.append_event(
766            run_id,
767            now_ms(),
768            &root_path(new_flow),
769            &RunLogPayload::RunSuspended {
770                provider_state_summary: Some(summary),
771                reason: Some(reason.to_string()),
772            },
773        )?;
774        let _ = session.end(SessionOutcome::Shutdown, None).await;
775        return Ok(RunOutcome::Blocked { reason });
776    }
777
778    let params = params_object(&view);
779    let env = env_bindings(&view.binding.device_id, opts.platform.as_deref(), run_id);
780    let exec = Execution {
781        flows: &loaded,
782        session,
783        store,
784        run_id: run_id.to_owned(),
785        stop: opts.stop,
786        stop_at: opts.stop_at.clone(),
787        stop_after: opts.stop_after.clone(),
788        env,
789        attempt_base: facts.max_attempt.clone(),
790        open_spans,
791        live_frames,
792        resumed: true,
793        authorized: opts.allow_mutating_reexec.iter().cloned().collect(),
794        reentry_seen: false,
795        adoptable,
796        frontier: frontier_work,
797        session_lineage: view.binding.session_lineage.clone(),
798        pending_summaries: BTreeMap::new(),
799        vision: opts.vision.clone(),
800        supervise: opts.supervise,
801        human: facts.human_requests.clone(),
802        settled: facts.settled.clone(),
803        recorded_verdicts: facts.recorded_verdicts.clone(),
804        hook_triggers: facts.hook_triggers.clone(),
805        clock: opts.clock,
806    };
807    let root = FrameState::new(new_flow, root_path(new_flow), params, 1);
808    exec.run(root, 0).await
809}
810
811// ─── cross-IR resume: the flat alignment subset (07 §5.2) ────────────────────
812
813/// Resumes under a repaired IR. M2 subset: the old records and the new
814/// body must both be flat top-level action steps; anything nested is a
815/// typed refusal (the 07 §5.2 nested alignment rules land with the repair
816/// wave).
817async fn resume_cross_ir(
818    loaded: LoadedFlow<'_>,
819    view: CheckpointView,
820    facts: Harvest,
821    run_id: &str,
822    mut session: Box<dyn ProviderSession>,
823    store: &mut Store,
824    opts: ResumeOptions,
825) -> Result<RunOutcome, RunnerError> {
826    let new_flow = loaded.root;
827    // Bind-time credential, as in resume_same_ir (07 §4.5).
828    let bind_cursor = store.run_meta(run_id)?.binding.event_cursor;
829    // The FRONTIER may not sit inside a handler frame: resolving it means
830    // walking a path `resolve_step` refuses by construction, and the
831    // reconcile below would then have no step to reconcile against.
832    // Completed hook-framed records are a different matter — see
833    // [`is_alignable_path`].
834    if !is_alignable_path(&view.frontier.run_path) {
835        let _ = session.end(SessionOutcome::Shutdown, None).await;
836        return Err(RunnerError::M0Unsupported {
837            detail: format!(
838                "the run's frontier sits inside a handler frame ({}); hook-aware frame \
839                 re-entry is registered for the repair wave",
840                pointlock_ir::render_run_path(&view.frontier.run_path)
841            ),
842        });
843    }
844
845    // Unfinished handler work in ANY of its three shapes is a categorical
846    // refusal, and it is settled BEFORE alignment runs — `align` can return
847    // `RequiresConfirmation`, and letting that mask a resume the runner
848    // would refuse outright would tell the operator to authorize step ids
849    // for something that can never proceed. It is also the order
850    // `align_preview` uses, and the preview promises to mirror resume's
851    // typed refusals.
852    //
853    // (i) a repair subflow suspended mid-flight — its call frame is still
854    // open.
855    if let Some(live_hook) = facts
856        .live_frames
857        .iter()
858        .find(|path| path.iter().any(|f| matches!(f, PathFrame::Hook { .. })))
859    {
860        let _ = session.end(SessionOutcome::Shutdown, None).await;
861        return Err(RunnerError::M0Unsupported {
862            detail: format!(
863                "resume across a live handler-repair frame ({}) is not in the M2 subset — \
864                 the repair flow suspended mid-flight; hook-aware frame re-entry is \
865                 registered for the repair wave",
866                pointlock_ir::render_run_path(live_hook)
867            ),
868        });
869    }
870    // (ii) an escalate hook human still awaiting an answer. It leaves NO
871    // other trace: it opens no span and pushes no frame (「hook humans are
872    // not body steps」), so `live_frames`, `frontier` and `completed` are
873    // all blind to it — `humanPending` is the only carrier. Cross-IR it is
874    // genuinely unsafe: the continuation is looked up by an instance key
875    // rebuilt from the NEW host path, so renaming the host mints a SECOND
876    // request and strands the first unanswerable, and deleting the host
877    // strands it forever. Same-IR rebuilds the same key and settles
878    // correctly, which is why this refusal lives here and not there.
879    if let Some(pending) = view
880        .human_pending
881        .as_ref()
882        .filter(|pending| !is_alignable_path(&pending.run_path))
883    {
884        let _ = session.end(SessionOutcome::Shutdown, None).await;
885        return Err(RunnerError::M0Unsupported {
886            detail: format!(
887                "a handler escalation is still awaiting an answer ({}); resuming it under a \
888                 repaired IR needs hook-aware re-entry, which is registered for the repair \
889                 wave — answer or let it time out first",
890                pointlock_ir::render_run_path(&pending.run_path)
891            ),
892        });
893    }
894
895    let seed = ScopeSeed::new(
896        params_object(&view),
897        &view.binding.device_id,
898        opts.platform.as_deref(),
899        run_id,
900    );
901
902    // (A) alignment first (07 §4.1). Classification runs on the archived
903    // per-step hashes the fold harvested from `stepEntered` (spine §6.1
904    // M1 note) — the old FlowIR is not required.
905    let mut alignment = match align(
906        &loaded,
907        &new_flow.body,
908        &view,
909        &facts,
910        &seed,
911        store,
912        opts.vision.as_deref(),
913        &opts.allow_mutating_reexec,
914        &opts.force_reexecute,
915        opts.old_flow_ir.as_ref(),
916    )
917    .await
918    {
919        Ok(alignment) => alignment,
920        Err(error) => {
921            // A pre-header refusal must not leak the opened session
922            // (best-effort teardown, 04 §2.1).
923            let _ = session.end(SessionOutcome::Shutdown, None).await;
924            return Err(error);
925        }
926    };
927
928    // (B) unconditional reconcile of a pending intent (07 §4.1/§4.4).
929    let mut frontier_work = None;
930    let mut deferred_settle = None;
931    let mut pending_adjudication: Option<Box<Adjudication>> = None;
932    let mut blocked = None;
933    if let Some(intent) = &view.frontier.pending_intent {
934        let frontier_key = instance_key(&view.frontier.run_path);
935        // Resolved by PATH, not by a flat id scan: the frontier can sit
936        // inside a branch, and same-IR resume already resolves it this way.
937        let new_step = loaded.resolve_action(&view.frontier.run_path);
938        // The frontier IS the resume point when its instance is the one
939        // alignment named. Instance keys, not body indices: the comparison
940        // has to keep working once the resume point can sit inside a
941        // callee or an iteration.
942        let at_resume = alignment.resume_key.as_deref() == Some(frontier_key.as_str());
943        // §4.1 cross semantics: an effect-dirty frontier step's old result
944        // is never adopted — it is the product of the old binding. A
945        // missing hash or a frontier step absent from the new IR fails
946        // closed (dirty).
947        let effect_dirty = match (new_step, facts.entered_effect_hash.get(&frontier_key)) {
948            (Some(step), Some(archived)) => *archived != step.base.effect_hash,
949            _ => true,
950        };
951        let decision = match reconcile_frontier(
952            &mut session,
953            new_step,
954            at_resume,
955            effect_dirty,
956            &view,
957            &facts,
958            &bind_cursor,
959            &mut alignment.report,
960            intent,
961        )
962        .await
963        {
964            Ok(decision) => decision,
965            Err(error) => {
966                let _ = session.end(SessionOutcome::Shutdown, None).await;
967                return Err(error);
968            }
969        };
970        match decision {
971            FrontierDecision::Work(work) => frontier_work = Some((frontier_key, work)),
972            FrontierDecision::DeferredSettle(settle) => deferred_settle = Some(settle),
973            FrontierDecision::Adjudicate(adjudication) => pending_adjudication = Some(adjudication),
974            FrontierDecision::Blocked(reason) => blocked = Some(reason),
975            FrontierDecision::Nothing => {}
976        }
977    }
978
979    // The segment header (see the same-IR site for the cursor semantics).
980    let resumed_cursor = session.current_cursor().await.ok();
981    store.append_event(
982        run_id,
983        now_ms(),
984        &root_path(new_flow),
985        &RunLogPayload::RunResumed {
986            alignment_report: alignment.report.clone(),
987            supervise_policy: opts.supervise,
988            event_cursor: resumed_cursor,
989        },
990    )?;
991
992    // Offline re-judgements: new verdicts with `supersedes` lineage,
993    // anchored at the old records' run paths (the fold re-projects the
994    // completed records); written back via the *current* session
995    // (07 §5.3 — cross-session write-back is sound, the daemon only
996    // persists).
997    let rejudged = std::mem::take(&mut alignment.rejudged);
998    for rejudge in rejudged {
999        // Remote archival first so its outcome rides the event; a
1000        // failure is annotation material, never a resume error (04 §5 —
1001        // the RunLog is the sole truth). Wire caps applied here like on
1002        // every other write-back: compaction is the runner's job (04 §5).
1003        let remote_archival_error = session
1004            .record_verdict(pointlock_provider_kit::VerdictWrite {
1005                status: rejudge.verdict.status,
1006                summary: crate::engine::cap_wire_summary(&rejudge.verdict),
1007                evidence: rejudge
1008                    .verdict
1009                    .evidence
1010                    .iter()
1011                    .take(pointlock_provider_kit::VERDICT_EVIDENCE_MAX_ENTRIES)
1012                    .cloned()
1013                    .collect(),
1014            })
1015            .await
1016            .err()
1017            .map(|error| format!("remote archival failed: {error}"));
1018        store.append_event(
1019            run_id,
1020            now_ms(),
1021            &rejudge.run_path,
1022            &RunLogPayload::VerdictRecorded {
1023                verdict: rejudge.verdict.clone(),
1024                localized: Vec::new(),
1025                localization_gaps: Vec::new(),
1026                remote_archival_error,
1027            },
1028        )?;
1029    }
1030
1031    // A reconciled completed terminal that cannot be adopted at the
1032    // resume point is still recorded — the ledger closes the intent
1033    // and keeps the world fact as evidence (07 §4.1).
1034    if let Some((path, call_id, outcome)) = deferred_settle {
1035        store.append_event(
1036            run_id,
1037            now_ms(),
1038            &path,
1039            &RunLogPayload::ActionSettled {
1040                call_id,
1041                outcome: crate::engine::quarantine_unpersistable(*outcome),
1042            },
1043        )?;
1044    }
1045
1046    if let Some(adjudication) = pending_adjudication {
1047        // Phase 1 of the 07 §4.4 default escalation: the request (fresh or
1048        // re-awaited) is the segment's outcome — the run suspends
1049        // `awaitingHuman` and the answer arrives through the ordinary
1050        // arbitration channel, durable for the next resume to consume.
1051        let Adjudication {
1052            run_path,
1053            request,
1054            pending,
1055        } = *adjudication;
1056        if let Some((request_id, prompt, presents)) = request {
1057            store.append_event(
1058                run_id,
1059                now_ms(),
1060                &run_path,
1061                &RunLogPayload::HumanRequested {
1062                    request_id,
1063                    purpose: pointlock_ir::HumanPurpose::Step,
1064                    mode: Some(pointlock_ir::vocab::HumanMode::RepairWorld),
1065                    prompt,
1066                    presents,
1067                    decisions: Some(vec![
1068                        "adopt".to_owned(),
1069                        "redo".to_owned(),
1070                        "abort".to_owned(),
1071                    ]),
1072                    output_schema: None,
1073                    deadline_at_ms: None,
1074                },
1075            )?;
1076        }
1077        let summary = crate::engine::capture_provider_state_summary(
1078            session.as_ref(),
1079            &view.binding.session_lineage,
1080            &view.binding.device_id,
1081            opts.platform.as_deref(),
1082        )
1083        .await;
1084        store.append_event(
1085            run_id,
1086            now_ms(),
1087            &root_path(new_flow),
1088            &RunLogPayload::RunSuspended {
1089                provider_state_summary: Some(summary),
1090                reason: Some(format!(
1091                    "awaiting human adjudication (requestId {})",
1092                    pending.request_id
1093                )),
1094            },
1095        )?;
1096        let _ = session.end(SessionOutcome::Shutdown, None).await;
1097        return Ok(RunOutcome::AwaitingHuman { pending });
1098    }
1099
1100    if let Some(reason) = blocked {
1101        // Suspension-instant profile (07 §2.2): the session is still
1102        // live at this pre-Execution blocked refusal.
1103        let summary = crate::engine::capture_provider_state_summary(
1104            session.as_ref(),
1105            &view.binding.session_lineage,
1106            &view.binding.device_id,
1107            opts.platform.as_deref(),
1108        )
1109        .await;
1110        store.append_event(
1111            run_id,
1112            now_ms(),
1113            &root_path(new_flow),
1114            &RunLogPayload::RunSuspended {
1115                provider_state_summary: Some(summary),
1116                reason: Some(reason.to_string()),
1117            },
1118        )?;
1119        let _ = session.end(SessionOutcome::Shutdown, None).await;
1120        return Ok(RunOutcome::Blocked { reason });
1121    }
1122
1123    let Alignment {
1124        adoptable,
1125        teardown,
1126        ..
1127    } = alignment;
1128    // 07 §5.2 case (b): dismantle the stale frame ON THE LEDGER before
1129    // execution starts, mirroring `exec_call`'s abort unwind exactly —
1130    // close the open spans innermost-first (the fold's exit pairing is
1131    // LIFO), pop each live frame right before its own call span closes,
1132    // and let the call step exit `aborted` (an aborted execution makes no
1133    // semantic claim; nothing here is adoptable history). Emitted AFTER
1134    // the deferred settle above, so a terminal the reconcile closed lands
1135    // on the still-open frontier span and is archived with it.
1136    //
1137    // The suspension chain is one nested sequence, so "the torn-down
1138    // subtree" is precisely the open spans at or under the call's own key.
1139    let torn = |key: &str| -> bool {
1140        teardown
1141            .as_deref()
1142            .is_some_and(|call| key == call || crate::align::is_instance_descendant(call, key))
1143    };
1144    if teardown.is_some() {
1145        let live_keys: BTreeSet<String> = facts
1146            .live_frames
1147            .iter()
1148            .map(|path| instance_key(path))
1149            .collect();
1150        for span in facts.open_spans.iter().rev() {
1151            let key = instance_key(span);
1152            if !torn(&key) {
1153                continue;
1154            }
1155            if live_keys.contains(&key) {
1156                // The span belongs to a call step whose frame is open: the
1157                // frame pops first, the span closes second — the exact
1158                // unwind order of a live abort.
1159                store.append_event(
1160                    run_id,
1161                    now_ms(),
1162                    span,
1163                    &RunLogPayload::CallFramePopped { outputs: None },
1164                )?;
1165            }
1166            store.append_event(
1167                run_id,
1168                now_ms(),
1169                span,
1170                &RunLogPayload::StepExited {
1171                    provider_state_summary: None,
1172                    state: pointlock_ir::StepState::Aborted,
1173                    output: None,
1174                    localized: Vec::new(),
1175                    localization_gaps: Vec::new(),
1176                },
1177            )?;
1178        }
1179    }
1180    let open_spans: BTreeMap<String, Value> = facts
1181        .open_spans
1182        .iter()
1183        .filter(|path| !torn(&instance_key(path)))
1184        .map(|path| {
1185            let key = instance_key(path);
1186            let inputs = facts
1187                .entered_inputs
1188                .get(&key)
1189                .cloned()
1190                .unwrap_or(Value::Null);
1191            (key, inputs)
1192        })
1193        .collect();
1194    // The torn-down frame is gone from the ledger; handing its pin to the
1195    // engine would make `exec_call` skip the push for a frame that no
1196    // longer exists.
1197    let live_frames: BTreeMap<String, pointlock_ir::Hash> = live_frame_pins(&facts)
1198        .into_iter()
1199        .filter(|(key, _)| !torn(key))
1200        .collect();
1201    let params = params_object(&view);
1202    let env = env_bindings(&view.binding.device_id, opts.platform.as_deref(), run_id);
1203    let exec = Execution {
1204        flows: &loaded,
1205        session,
1206        store,
1207        run_id: run_id.to_owned(),
1208        stop: opts.stop,
1209        stop_at: opts.stop_at.clone(),
1210        stop_after: opts.stop_after.clone(),
1211        env,
1212        attempt_base: facts.max_attempt.clone(),
1213        open_spans,
1214        // Live call frames must not be pushed again on resume (07 §4.6);
1215        // the pin lets `exec_call` tell a plain re-entry from one that has
1216        // to rebase the frame onto a repaired callee (07 §5.2 case (a)).
1217        // The torn-down frame (case (b)) is filtered out above.
1218        live_frames,
1219        resumed: true,
1220        authorized: opts.allow_mutating_reexec.iter().cloned().collect(),
1221        reentry_seen: false,
1222        adoptable,
1223        frontier: frontier_work,
1224        session_lineage: view.binding.session_lineage.clone(),
1225        pending_summaries: BTreeMap::new(),
1226        vision: opts.vision.clone(),
1227        supervise: opts.supervise,
1228        human: facts.human_requests.clone(),
1229        settled: facts.settled.clone(),
1230        recorded_verdicts: facts.recorded_verdicts.clone(),
1231        hook_triggers: facts.hook_triggers.clone(),
1232        clock: opts.clock,
1233    };
1234    // Execution restarts at the top of the body; the adoption set does the
1235    // skipping, seeding each adopted step's output/verdict into its OWN
1236    // frame as it is reached. That is the same mechanism same-IR resume
1237    // uses, and the only one that can express a resume point at depth.
1238    let root = FrameState::new(new_flow, root_path(new_flow), params, 1);
1239    exec.run(root, 0).await
1240}
1241
1242/// Whether cross-IR alignment can ADDRESS this path.
1243///
1244/// Exactly a hook guard, and says so rather than re-listing the seven
1245/// frames it accepts: the walker descends `if` branch bodies, `foreach`
1246/// rounds, and — under the case (a) down-drill — callee bodies, addressing
1247/// every one of them by instance key, so `flow`/`step`/`call`/`iteration`
1248/// (and the attempt/phase/assertion suffixes) are all classifiable. `hook`
1249/// is the one frame shape nothing addresses.
1250///
1251/// Applied to the FRONTIER only. Completed hook-framed records are not
1252/// refused — 07 §5.2's last bullet rules 「hook 帧下的记录(handler 审计
1253/// 痕)不参与对齐复用……旧 hook 记录一律归档」: archive them, do not refuse
1254/// the resume. Refusing cost a real case — a run whose `onFail` repair
1255/// subflow completed could never be repaired cross-IR afterwards — and
1256/// archival is already structural rather than a promise:
1257/// - they are never ADOPTED: adoption is keyed by instance, and node keys
1258///   come from `child_frame`, which emits only `step`/`call`/`iteration`
1259///   segments. `instance_key` renders a hook frame as `/hook:<Hook>:<n>`,
1260///   which no `StepId` can spell, so no node key can ever collide;
1261/// - they are never ORPHAN-reported: the only hook-framed `StepRecord`s
1262///   come from a repair subflow's body, whose path always carries the
1263///   handler-launched `call` frame, and the orphan pass skips records
1264///   under a call frame the walk did not descend into. An escalate human
1265///   writes `humanRequested` and no step span at all, so it contributes
1266///   no record to misreport.
1267///
1268/// A LIVE hook frame is still refused, separately and before this: a
1269/// repair subflow suspended mid-flight needs hook-aware frame re-entry,
1270/// which is the repair wave's.
1271fn is_alignable_path(path: &RunPath) -> bool {
1272    !path
1273        .iter()
1274        .any(|frame| matches!(frame, PathFrame::Hook { .. }))
1275}
1276
1277/// A pending human adjudication of an uncertain reconcile (07 §4.4): the
1278/// run suspends `awaitingHuman` on a synthesized `repairWorld` request
1279/// whose vocabulary is `adopt | redo | abort` (00 §6.7-B). Paired to its
1280/// intent BY CALL ID (carried in `presents`), so an answer ruled for one
1281/// dispatch can never be replayed onto a later one.
1282struct Adjudication {
1283    /// The hook-framed anchor (`<frontier>/hook:OnResumeDrift:1/adjudicate`).
1284    run_path: RunPath,
1285    /// A fresh request to append — `(requestId, prompt, presents)`; `None`
1286    /// when an unanswered request for this callId is already on the ledger
1287    /// and the segment simply re-awaits it.
1288    request: Option<(String, String, Value)>,
1289    /// What the segment reports as the pending interaction.
1290    pending: pointlock_ir::HumanPending,
1291}
1292
1293/// What the frontier reconcile decided.
1294enum FrontierDecision {
1295    /// Mid-flight work for the resume step.
1296    Work(FrontierWork),
1297    /// Close the intent in the ledger with the archived terminal; the step
1298    /// re-executes fresh.
1299    DeferredSettle(
1300        (
1301            pointlock_ir::RunPath,
1302            String,
1303            Box<pointlock_ir::ActionOutcome>,
1304        ),
1305    ),
1306    /// Human adjudication required: suspend `awaitingHuman` on the
1307    /// adjudication request (fresh or re-awaited).
1308    Adjudicate(Box<Adjudication>),
1309    /// Human adjudication impossible to even request (defense line).
1310    Blocked(BlockedReason),
1311    /// Nothing to carry over (e.g. neverDispatched off the resume point).
1312    Nothing,
1313}
1314
1315/// Applies the 07 §4.4 decision table to a pending intent. `new_step` is
1316/// the frontier step as resolved in the new IR (nested paths supported);
1317/// `at_resume` states whether execution will land exactly on it;
1318/// `effect_dirty` is the §4.1 cross-semantics discriminator.
1319#[allow(clippy::too_many_arguments)]
1320async fn reconcile_frontier(
1321    session: &mut Box<dyn ProviderSession>,
1322    new_step: Option<&ActionStepIR>,
1323    at_resume: bool,
1324    effect_dirty: bool,
1325    view: &CheckpointView,
1326    facts: &Harvest,
1327    bind_cursor: &pointlock_ir::EventCursor,
1328    report: &mut AlignmentReport,
1329    intent: &pointlock_ir::PendingIntent,
1330) -> Result<FrontierDecision, RunnerError> {
1331    // The issuing credential (07 §4.5): per-intent exact state from the
1332    // ledger scan. `FromBinding` (no resume preceded the intent) resolves
1333    // to the BIND-TIME cursor — the run-row binding written once at
1334    // begin_run — NOT the folded view's cursor, which every
1335    // cursor-bearing resume reseeds to the newest generation (a
1336    // generation that never issued this intent). A missing harvest entry
1337    // means the ledger cannot attest the issuing generation at all:
1338    // fail-closed to Unknown, never a fabricated credential. Unknown is
1339    // answered with the uncertain branch WITHOUT an RPC.
1340    let issuing = facts
1341        .intent_issuing
1342        .get(&intent.call_id)
1343        .cloned()
1344        .unwrap_or(crate::align::IssuingCursor::Unknown);
1345    let fate = match &issuing {
1346        crate::align::IssuingCursor::FromBinding => {
1347            session.reconcile(&intent.call_id, bind_cursor).await?
1348        }
1349        crate::align::IssuingCursor::Known(cursor) => {
1350            session.reconcile(&intent.call_id, cursor).await?
1351        }
1352        crate::align::IssuingCursor::Unknown => ReconcileResult::LogUnavailable {
1353            reason: "the issuing session is unknowable (a resume predating the \
1354                     eventCursor carrier intervened); refusing to reconcile with \
1355                     a fabricated credential"
1356                .to_owned(),
1357        },
1358    };
1359    let intent_path = facts
1360        .intent_path
1361        .get(&intent.call_id)
1362        .cloned()
1363        .unwrap_or_else(|| view.frontier.run_path.clone());
1364    let mutating_gated = new_step.map(gated_mutating).unwrap_or(true);
1365
1366    match fate {
1367        ReconcileResult::Completed { outcome } => {
1368            if !effect_dirty && at_resume {
1369                // The archived terminal — whatever its four-way
1370                // discriminant — is adopted and disposed through the same
1371                // settled-outcome path as a live execute (§6.7-B).
1372                return Ok(FrontierDecision::Work(FrontierWork::Adopt {
1373                    call_id: intent.call_id.clone(),
1374                    intent_path,
1375                    outcome,
1376                    args: intent.args_snapshot.clone(),
1377                    chain_index: facts.intent_chain_index.get(&intent.call_id).copied(),
1378                }));
1379            }
1380            // Not adoptable at the resume point. Whether re-execution
1381            // risks a second effect follows the 07 §5.4 criterion: only a
1382            // succeeded or timedOut terminal can have mutated the world;
1383            // an archived failed/cancelled left no effect to double.
1384            let effect_possible = matches!(
1385                outcome.as_ref(),
1386                pointlock_ir::ActionOutcome::Succeeded { .. }
1387                    | pointlock_ir::ActionOutcome::TimedOut { .. }
1388            );
1389            if effect_possible && mutating_gated {
1390                // The old action (possibly) took effect but its terminal
1391                // cannot be adopted (effect-dirty or positionally
1392                // invalidated): re-execution is a second effect —
1393                // 07 §5.4 `frontierUnknown`, fail-closed.
1394                report.requires_confirmation.push(RequiresConfirmation {
1395                    run_path: view.frontier.run_path.clone(),
1396                    step_id: new_step.map(|step| step.base.step_id.clone()),
1397                    cause: "frontierUnknown".to_owned(),
1398                    reason: format!(
1399                        "callId {} reached a recorded {} terminal on the device but \
1400                         it is not adoptable; re-execution of the mutating step \
1401                         needs explicit authorization",
1402                        intent.call_id,
1403                        outcome.kind()
1404                    ),
1405                });
1406                return Err(RunnerError::RequiresConfirmation {
1407                    report: Box::new(report.clone()),
1408                });
1409            }
1410            Ok(FrontierDecision::DeferredSettle((
1411                intent_path,
1412                intent.call_id.clone(),
1413                outcome,
1414            )))
1415        }
1416        ReconcileResult::NeverDispatched => {
1417            if !effect_dirty && at_resume {
1418                // Safe replay: archived args, new callId, new WAL intent.
1419                Ok(FrontierDecision::Work(FrontierWork::Replay {
1420                    chain_index: facts.intent_chain_index.get(&intent.call_id).copied(),
1421                    args: intent.args_snapshot.clone(),
1422                }))
1423            } else {
1424                // The step re-executes fresh from ready (nothing happened
1425                // in the world).
1426                Ok(FrontierDecision::Nothing)
1427            }
1428        }
1429        ReconcileResult::StartedNoTerminal => uncertain_branch(
1430            new_step,
1431            intent,
1432            &view.frontier.run_path,
1433            facts,
1434            report,
1435            at_resume,
1436            effect_dirty,
1437            "startedNoTerminal",
1438            facts.intent_chain_index.get(&intent.call_id).copied(),
1439        ),
1440        ReconcileResult::LogUnavailable { reason } => uncertain_branch(
1441            new_step,
1442            intent,
1443            &view.frontier.run_path,
1444            facts,
1445            report,
1446            at_resume,
1447            effect_dirty,
1448            &format!("logUnavailable: {reason}"),
1449            facts.intent_chain_index.get(&intent.call_id).copied(),
1450        ),
1451    }
1452}
1453
1454/// The uncertain reconcile branch (07 §4.4): replay only with the explicit
1455/// author permission (`idempotent` / `readonly`); otherwise the DEFAULT
1456/// `onResumeDrift` escalation — a synthesized `repairWorld` human rules
1457/// `adopt | redo | abort` over the presented callId (00 §6.7-B). The
1458/// request and its answer live on the ordinary human ledger
1459/// (`humanRequested`/`humanResponded`), so the operator answers through
1460/// the same channels as any other wait and the ruling is durable: a crash
1461/// after the answer re-derives the same disposition.
1462///
1463/// A DECLARED `onResumeDrift` binding keeps serving the probe-drift ladder
1464/// it was written for; routing the reconcile adjudication through custom
1465/// bindings is registered for the repair wave.
1466#[allow(clippy::too_many_arguments)]
1467fn uncertain_branch(
1468    new_step: Option<&ActionStepIR>,
1469    intent: &pointlock_ir::PendingIntent,
1470    frontier_path: &RunPath,
1471    facts: &Harvest,
1472    report: &mut AlignmentReport,
1473    at_resume: bool,
1474    effect_dirty: bool,
1475    fate: &str,
1476    chain_index: Option<u32>,
1477) -> Result<FrontierDecision, RunnerError> {
1478    let permitted = new_step.map(replay_permitted).unwrap_or(false);
1479    if permitted {
1480        if at_resume && !effect_dirty {
1481            return Ok(FrontierDecision::Work(FrontierWork::Replay {
1482                chain_index,
1483                args: intent.args_snapshot.clone(),
1484            }));
1485        }
1486        // Fresh re-execution is equally safe for readonly/idempotent.
1487        return Ok(FrontierDecision::Nothing);
1488    }
1489
1490    // The adjudication anchor: one hook-framed instance under the frontier
1491    // step. The leaf id is fixed — identity per INTENT comes from the
1492    // callId carried in `presents`, checked below, so an answer ruled for
1493    // an earlier dispatch is never replayed onto this one.
1494    let mut hook_path = frontier_path.clone();
1495    hook_path.push(PathFrame::Hook {
1496        hook: pointlock_ir::HandlerHook::OnResumeDrift,
1497        trigger: 1,
1498    });
1499    hook_path.push(PathFrame::Step {
1500        step_id: "adjudicate".try_into().expect("a fixed valid step id"),
1501    });
1502    let key = instance_key(&hook_path);
1503
1504    if let Some(fact) = facts.human_requests.get(&key)
1505        && fact.presents.get("callId").and_then(Value::as_str) == Some(intent.call_id.as_str())
1506    {
1507        match &fact.final_response {
1508            None => {
1509                // Asked and unanswered: re-await the same request, no
1510                // duplicate append.
1511                return Ok(FrontierDecision::Adjudicate(Box::new(Adjudication {
1512                    run_path: hook_path.clone(),
1513                    request: None,
1514                    pending: pending_of(fact, &hook_path),
1515                })));
1516            }
1517            Some(response) => {
1518                let ruling = response.get("decision").and_then(Value::as_str);
1519                match ruling {
1520                    Some("adopt") => {
1521                        if at_resume && !effect_dirty {
1522                            // The ruled effect stands; the step's own
1523                            // assertions verify it over a fresh
1524                            // observation ([`FrontierWork::ConfirmEffect`]).
1525                            return Ok(FrontierDecision::Work(FrontierWork::ConfirmEffect {
1526                                message: format!(
1527                                    "uncertain fate ({fate}) of callId {} adjudicated \
1528                                     `adopt`",
1529                                    intent.call_id
1530                                ),
1531                                args: intent.args_snapshot.clone(),
1532                            }));
1533                        }
1534                        // Adopted effect on a step that must nonetheless
1535                        // re-execute (effect-dirty / positionally
1536                        // invalidated): a second effect — the 07 §5.4
1537                        // frontierUnknown gate, same as an unadoptable
1538                        // recorded terminal.
1539                        report.requires_confirmation.push(RequiresConfirmation {
1540                            run_path: frontier_path.clone(),
1541                            step_id: new_step.map(|step| step.base.step_id.clone()),
1542                            cause: "frontierUnknown".to_owned(),
1543                            reason: format!(
1544                                "callId {} was adjudicated `adopt` (the effect stands) but \
1545                                 the step is not adoptable here; re-execution of the \
1546                                 mutating step needs explicit authorization",
1547                                intent.call_id
1548                            ),
1549                        });
1550                        return Err(RunnerError::RequiresConfirmation {
1551                            report: Box::new(report.clone()),
1552                        });
1553                    }
1554                    Some("redo") => {
1555                        // I2 source (iv): the human's redo IS the license.
1556                        if at_resume && !effect_dirty {
1557                            return Ok(FrontierDecision::Work(FrontierWork::Replay {
1558                                chain_index,
1559                                args: intent.args_snapshot.clone(),
1560                            }));
1561                        }
1562                        return Ok(FrontierDecision::Nothing);
1563                    }
1564                    Some("abort") => {
1565                        return Ok(FrontierDecision::Work(FrontierWork::AbortRuled {
1566                            args: intent.args_snapshot.clone(),
1567                        }));
1568                    }
1569                    other => {
1570                        // The store arbitrates against the declared
1571                        // vocabulary, so this is a ledger anomaly — the
1572                        // defense line blocks rather than guesses.
1573                        return Ok(FrontierDecision::Blocked(BlockedReason::RequiresHuman {
1574                            call_id: intent.call_id.clone(),
1575                            detail: format!(
1576                                "adjudication response carries an unusable decision \
1577                                 {other:?}; refusing to guess"
1578                            ),
1579                        }));
1580                    }
1581                }
1582            }
1583        }
1584    }
1585
1586    // No adjudication asked yet (or the one on the ledger belongs to an
1587    // earlier dispatch): mint the request.
1588    let request_id = uuid::Uuid::new_v4().to_string();
1589    let prompt = format!(
1590        "the fate of callId {} is uncertain ({fate}) and the step is mutating and \
1591         not idempotent — automatic replay is forbidden (I2). Inspect the device, \
1592         then rule: `adopt` (the effect happened; verify and continue), `redo` \
1593         (the effect did not happen or you undid it; dispatch again), or `abort` \
1594         (stop the run)",
1595        intent.call_id
1596    );
1597    let presents = serde_json::json!({
1598        "callId": intent.call_id,
1599        "fate": fate,
1600        "argsSnapshot": intent.args_snapshot,
1601    });
1602    let pending = pointlock_ir::HumanPending {
1603        run_path: hook_path.clone(),
1604        request_id: request_id.clone(),
1605        purpose: pointlock_ir::HumanPurpose::Step,
1606        mode: Some(pointlock_ir::vocab::HumanMode::RepairWorld),
1607        prompt: prompt.clone(),
1608        deadline_at_ms: None,
1609    };
1610    Ok(FrontierDecision::Adjudicate(Box::new(Adjudication {
1611        run_path: hook_path,
1612        request: Some((request_id, prompt, presents)),
1613        pending,
1614    })))
1615}
1616
1617/// The pending descriptor of an already-asked adjudication.
1618fn pending_of(fact: &HumanRequestFact, hook_path: &RunPath) -> pointlock_ir::HumanPending {
1619    pointlock_ir::HumanPending {
1620        run_path: hook_path.clone(),
1621        request_id: fact.request_id.clone(),
1622        purpose: fact.purpose,
1623        mode: fact.mode,
1624        prompt: fact.prompt.clone(),
1625        deadline_at_ms: fact.deadline_at_ms,
1626    }
1627}
1628
1629/// The params snapshot of a checkpoint as an object map (it was written by
1630/// `Runner::run` as an object; anything else folds to empty).
1631fn params_object(view: &CheckpointView) -> Map<String, Value> {
1632    match &view.params_snapshot {
1633        Value::Object(map) => map.clone(),
1634        _ => Map::new(),
1635    }
1636}