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}