Skip to main content

forge_pilot/
loop_runner.rs

1//! OODA loop runner that drives repeated observe-orient-decide-act cycles.
2//!
3//! `LoopRunner` owns the runtime resources and pilot history, runs up to
4//! `max_iterations` cycles, and produces a `LoopReport` summarizing all
5//! iterations, halt reason, and receipts.
6
7use crate::act::execute_plan;
8use crate::config::LoopConfig;
9use crate::decide::select_candidate;
10use crate::error::PilotError;
11use crate::export::canonical_roundtrip;
12use crate::history::PilotHistory;
13use crate::observe::{observe_scope, Observation, ObservationDisposition};
14use crate::orient::score_targets;
15use crate::receipts::{
16    build_execution_context, build_export_trace, build_lineage_receipt, build_repair_record,
17    build_stop_rule_evaluation, build_verification_plan_artifact,
18};
19use crate::types::RepairClassV1;
20use crate::ActionFamily;
21use forge_engine::ForgeStore;
22use knowledge_runtime::adapters::semantic_memory::SemanticMemoryAdapter;
23use knowledge_runtime::{KnowledgeRuntime, RuntimeConfig};
24use semantic_memory::MemoryStore;
25use serde_json::json;
26use stack_ids::{AttemptId, TraceCtx, TrialId};
27use std::sync::{
28    atomic::{AtomicBool, Ordering},
29    Arc,
30};
31use tokio::time::{sleep, Duration, Instant};
32use verification_adjudication::adjudicate_case;
33use verification_calibration::CalibrationSnapshot;
34use verification_control::{
35    replay_case, schedule_check_plan, BudgetLineage, CaseRegion, CheckPlan, ControlReceipt,
36    DegradationMarker, LedgerEntry, LedgerEvent, QueueHop, VerificationAttempt,
37    VerificationAttemptState, VerificationCase,
38};
39use verification_policy::{approval_matches, evaluate_policy, policy_as_of};
40
41#[path = "loop_runner_helpers.rs"]
42mod loop_runner_helpers;
43#[path = "loop_runner_report.rs"]
44mod loop_runner_report;
45
46use loop_runner_helpers::{
47    currently_promoted, execute_rollback_invalidation, map_check_method, plan_input,
48    promotion_class_for_plan, reversibility_class_for_plan,
49};
50use loop_runner_report::apply_loop_report_boundary_repairs;
51pub use loop_runner_report::{
52    parse_loop_report_boundary, HaltReason, LoopBudgetReceipt, LoopIterationReport, LoopReceipt,
53    LoopReport, LOOP_ITERATION_REPORT_V1_SCHEMA, LOOP_REPORT_V1_SCHEMA,
54    PILOT_LOOP_RECEIPT_V1_SCHEMA,
55};
56
57/// Runtime resources required by the loop runner: knowledge runtime, memory store, and Forge store.
58pub struct LoopRunnerResources {
59    pub runtime: KnowledgeRuntime,
60    pub memory_store: MemoryStore,
61    pub forge_store: ForgeStore,
62}
63
64impl LoopRunnerResources {
65    /// Builds loop-runner resources from stores plus runtime configuration.
66    pub fn from_memory_store(
67        memory_store: MemoryStore,
68        forge_store: ForgeStore,
69        runtime_config: RuntimeConfig,
70    ) -> Result<Self, PilotError> {
71        let runtime = KnowledgeRuntime::new(
72            runtime_config,
73            SemanticMemoryAdapter::new(memory_store.clone()),
74        )?;
75        Ok(Self {
76            runtime,
77            memory_store,
78            forge_store,
79        })
80    }
81}
82
83/// Thread-safe flag for requesting an early halt of the running loop.
84#[derive(Debug, Clone, Default)]
85pub struct ExternalHaltFlag {
86    flag: Arc<AtomicBool>,
87}
88
89impl ExternalHaltFlag {
90    /// Requests that the active loop stop at the next safe halt point.
91    pub fn halt(&self) {
92        self.flag.store(true, Ordering::SeqCst);
93    }
94
95    fn is_halted(&self) -> bool {
96        self.flag.load(Ordering::SeqCst)
97    }
98}
99
100/// Stateful OODA loop runner that executes repeated verification cycles.
101pub struct LoopRunner {
102    config: LoopConfig,
103    resources: LoopRunnerResources,
104    history: PilotHistory,
105    external_halt: ExternalHaltFlag,
106}
107
108struct LoopTotals {
109    targets_investigated: Vec<String>,
110    actions_executed: u32,
111    exports_completed: u32,
112    imports_completed: u32,
113    degraded_iterations: u32,
114    advisory_only_steps: bool,
115}
116
117impl LoopRunner {
118    /// Creates a loop runner with fresh history and halt state.
119    pub fn new(config: LoopConfig, resources: LoopRunnerResources) -> Self {
120        Self {
121            config,
122            resources,
123            history: PilotHistory::default(),
124            external_halt: ExternalHaltFlag::default(),
125        }
126    }
127
128    /// Returns a cloneable external halt flag for this runner.
129    pub fn halt_flag(&self) -> ExternalHaltFlag {
130        self.external_halt.clone()
131    }
132
133    /// Borrows the accumulated pilot history.
134    pub fn history(&self) -> &PilotHistory {
135        &self.history
136    }
137
138    /// Runs a single observation against the configured scope and stores.
139    pub async fn observe(&self) -> Result<Observation, PilotError> {
140        observe_scope(
141            &self.resources.runtime,
142            &self.resources.memory_store,
143            &self.config,
144        )
145        .await
146    }
147
148    /// Runs the full OODA loop until a halt condition is reached.
149    pub async fn run(&mut self) -> Result<LoopReport, PilotError> {
150        let started = Instant::now();
151        let started_at = chrono::Utc::now();
152        let trace_ctx = TraceCtx::generate();
153        let attempt_id = AttemptId::generate();
154        let mut iterations = Vec::new();
155        let mut totals = LoopTotals {
156            targets_investigated: Vec::new(),
157            actions_executed: 0,
158            exports_completed: 0,
159            imports_completed: 0,
160            degraded_iterations: 0,
161            advisory_only_steps: false,
162        };
163        let mut last_observation: Option<Arc<Observation>> = None;
164
165        for iteration_index in 0..self.config.max_iterations {
166            if self.external_halt.is_halted() {
167                return Ok(self.finish_report(
168                    started_at,
169                    trace_ctx.clone(),
170                    attempt_id.clone(),
171                    started,
172                    iterations,
173                    totals,
174                    HaltReason::ExternalHalt,
175                    last_observation.as_deref(),
176                ));
177            }
178            if started.elapsed().as_secs() >= self.config.time_budget_secs {
179                return Ok(self.finish_report(
180                    started_at,
181                    trace_ctx.clone(),
182                    attempt_id.clone(),
183                    started,
184                    iterations,
185                    totals,
186                    HaltReason::TimeBudgetExhausted,
187                    last_observation.as_deref(),
188                ));
189            }
190
191            let iteration_timestamp = chrono::Utc::now();
192            let observation = self.observe().await?;
193            let observation = Arc::new(observation);
194            last_observation = Some(Arc::clone(&observation));
195            let degraded = !observation.degradations.is_empty();
196            if degraded {
197                totals.degraded_iterations += 1;
198            }
199            if matches!(
200                observation.status.disposition,
201                ObservationDisposition::NamespaceMismatch
202            ) {
203                return Ok(self.finish_report(
204                    started_at,
205                    trace_ctx.clone(),
206                    attempt_id.clone(),
207                    started,
208                    iterations,
209                    totals,
210                    HaltReason::NamespaceMismatch,
211                    Some(&*observation),
212                ));
213            }
214            if matches!(
215                observation.status.disposition,
216                ObservationDisposition::StorageUnavailable
217            ) {
218                return Ok(self.finish_report(
219                    started_at,
220                    trace_ctx.clone(),
221                    attempt_id.clone(),
222                    started,
223                    iterations,
224                    totals,
225                    HaltReason::StorageUnavailable,
226                    Some(&*observation),
227                ));
228            }
229            if matches!(
230                observation.status.disposition,
231                ObservationDisposition::StorageCorrupt
232            ) {
233                return Ok(self.finish_report(
234                    started_at,
235                    trace_ctx.clone(),
236                    attempt_id.clone(),
237                    started,
238                    iterations,
239                    totals,
240                    HaltReason::StorageCorrupt,
241                    Some(&*observation),
242                ));
243            }
244            if observation.missing_kernel_payload() && observation.claim_versions.is_empty() {
245                return Ok(self.finish_report(
246                    started_at,
247                    trace_ctx.clone(),
248                    attempt_id.clone(),
249                    started,
250                    iterations,
251                    totals,
252                    HaltReason::MissingKernelPayload,
253                    Some(&*observation),
254                ));
255            }
256
257            // --- Governance gate evaluation ---
258            #[cfg(feature = "governance")]
259            let governance_gate_result = observation.governance.as_ref().map(|gov_obs| {
260                let gate = crate::governance_gate::gate_execution(gov_obs);
261                let receipt = crate::governance_gate::build_governance_receipt(gov_obs, &gate);
262                (gate, receipt)
263            });
264
265            #[cfg(feature = "governance")]
266            if let Some((ref gate, ref receipt)) = governance_gate_result {
267                match gate {
268                    crate::governance_gate::GovernanceGateResult::Blocked { reason } => {
269                        tracing::warn!(reason = %reason, "governance gate blocked execution");
270                        let governance_receipt = Some(receipt.clone());
271                        iterations.push(LoopIterationReport {
272                            schema_version: LOOP_ITERATION_REPORT_V1_SCHEMA.into(),
273                            iteration: iteration_index + 1,
274                            selected_target_key: None,
275                            action_family: None,
276                            export_completed: false,
277                            import_completed: false,
278                            degraded,
279                            advisory_only: false,
280                            outcome_signature: None,
281                            verification_case: None,
282                            check_plan: None,
283                            verification_attempt: None,
284                            scheduler_decision: None,
285                            policy_decision: None,
286                            approval_records: Vec::new(),
287                            calibration_snapshot: None,
288                            adjudication: None,
289                            control_receipt: None,
290                            target_normalization: None,
291                            decision_audit: None,
292                            lineage_receipt: None,
293                            export_trace: None,
294                            stop_rule_evaluation: None,
295                            verification_plan_artifact: None,
296                            boundary_repairs: Vec::new(),
297                            repair_records: Vec::new(),
298                            rollback_invalidation_count: None,
299                            #[cfg(feature = "governance")]
300                            governance_receipt,
301                        });
302                        let halt_reason = if reason.contains("incident") {
303                            HaltReason::GovernanceIncidentHalt
304                        } else {
305                            HaltReason::GovernanceAuthorityInsufficient
306                        };
307                        return Ok(self.finish_report(
308                            started_at,
309                            trace_ctx.clone(),
310                            attempt_id.clone(),
311                            started,
312                            iterations,
313                            totals,
314                            halt_reason,
315                            Some(&*observation),
316                        ));
317                    }
318                    crate::governance_gate::GovernanceGateResult::AdvisoryOnly { reason } => {
319                        tracing::info!(reason = %reason, "governance gate advisory-only mode");
320                        totals.advisory_only_steps = true;
321                    }
322                    crate::governance_gate::GovernanceGateResult::Allow => {}
323                }
324            }
325
326            let candidates = score_targets(&observation, &self.history, &self.config);
327            let decision = select_candidate(candidates, &self.history, &self.config);
328            let Some(selected) = decision.selected else {
329                let halt_reason = if totals.advisory_only_steps {
330                    HaltReason::AdvisoryOnlyFallback
331                } else if decision.exhausted_candidate_count > 0 {
332                    if totals.advisory_only_steps {
333                        HaltReason::AdvisoryOnlyFallback
334                    } else {
335                        HaltReason::AllTargetsExhausted
336                    }
337                } else if observation.thin_export_active() {
338                    HaltReason::ThinExportBlockedExecution
339                } else if matches!(
340                    observation.status.disposition,
341                    ObservationDisposition::ImportRequired
342                ) {
343                    HaltReason::ImportRequired
344                } else {
345                    HaltReason::NothingWorthInvestigating
346                };
347                return Ok(self.finish_report(
348                    started_at,
349                    trace_ctx.clone(),
350                    attempt_id.clone(),
351                    started,
352                    iterations,
353                    totals,
354                    halt_reason,
355                    Some(&*observation),
356                ));
357            };
358
359            self.history.mark_selected(&selected.stable_key);
360            totals
361                .targets_investigated
362                .push(selected.stable_key.clone());
363            let iteration_started_at = iteration_timestamp.to_rfc3339();
364            let trial_id = TrialId::generate();
365            let verification_case = VerificationCase::new(
366                selected.target.case_class(),
367                CaseRegion {
368                    namespace: self.config.scope.namespace.clone(),
369                    scope_key: Some(self.config.scope.key()),
370                    target_key: selected.stable_key.clone(),
371                    region_id: None,
372                    region_digest_id: None,
373                    claim_version_id: selected.target.primary_claim_version_id(),
374                    as_of_recorded_at: observation
375                        .import_log
376                        .as_ref()
377                        .map(|row| row.imported_at.clone()),
378                },
379                trace_ctx.clone(),
380                attempt_id.clone(),
381                iteration_started_at.clone(),
382                degraded,
383                matches!(
384                    selected.plan,
385                    crate::act::PlanKind::AdvisoryOnlyVerificationPlan(_)
386                ),
387            );
388            let check_plan = CheckPlan::new(
389                verification_case.case_id.clone(),
390                map_check_method(&selected.plan),
391                selected.plan.check_names(),
392                promotion_class_for_plan(&selected.plan, degraded),
393                reversibility_class_for_plan(&selected.plan),
394                !matches!(
395                    selected.plan,
396                    crate::act::PlanKind::AdvisoryOnlyVerificationPlan(_)
397                ) && !degraded,
398                matches!(
399                    selected.plan,
400                    crate::act::PlanKind::AdvisoryOnlyVerificationPlan(_)
401                ),
402                degraded,
403                selected.rationale.clone(),
404                plan_input(&selected.plan),
405            );
406            let mut check_plan = check_plan;
407            check_plan.target_exactness = if degraded {
408                semantic_memory_forge::ExactnessLevelV1::Conservative
409            } else {
410                semantic_memory_forge::ExactnessLevelV1::Exact
411            };
412            check_plan.evidence_admissibility = if degraded {
413                semantic_memory_forge::EvidenceAdmissibilityV1::Restricted
414            } else {
415                semantic_memory_forge::EvidenceAdmissibilityV1::Admissible
416            };
417            check_plan.cheap_check_ladder = decision
418                .audit
419                .as_ref()
420                .map(|audit| audit.cheap_check_ladder.clone())
421                .unwrap_or_default();
422            check_plan.proof_obligations_remaining = if degraded
423                || matches!(
424                    selected.plan,
425                    crate::act::PlanKind::AdvisoryOnlyVerificationPlan(_)
426                ) {
427                vec!["exact proof path unavailable".into()]
428            } else {
429                Vec::new()
430            };
431            check_plan.proof_profile = Some(verification_control::ProofProfileV1 {
432                profile_name: "forge-pilot.loop".into(),
433                evidence_admissibility: check_plan.evidence_admissibility.clone(),
434                cheap_checks: check_plan.cheap_check_ladder.clone(),
435                proof_obligations_remaining: check_plan.proof_obligations_remaining.clone(),
436                admissible_evidence: if degraded {
437                    vec!["control_receipt".into(), "advisory_oracle_slice".into()]
438                } else {
439                    vec![
440                        "control_receipt".into(),
441                        "replay_lineage".into(),
442                        "oracle_slice".into(),
443                    ]
444                },
445            });
446            let policy_snapshot =
447                policy_as_of(&self.config.policy_snapshots, &iteration_started_at)
448                    .cloned()
449                    .unwrap_or_else(|| {
450                        self.config
451                            .policy_snapshots
452                            .first()
453                            .cloned()
454                            .unwrap_or_else(|| {
455                                verification_policy::PolicySnapshot::permissive(
456                                    "forge-pilot.fallback",
457                                    iteration_started_at.clone(),
458                                )
459                            })
460                    });
461            let budget_exhausted = started.elapsed().as_secs() >= self.config.time_budget_secs;
462            let policy_decision = evaluate_policy(
463                &policy_snapshot,
464                &verification_case,
465                &check_plan,
466                &self.config.approval_records,
467                degraded,
468                budget_exhausted,
469            );
470            let approval_records = self
471                .config
472                .approval_records
473                .iter()
474                .filter(|approval| {
475                    approval.policy_version == policy_snapshot.policy_version
476                        && approval_matches(
477                            approval,
478                            &verification_case,
479                            &check_plan,
480                            iteration_started_at.as_str(),
481                        )
482                })
483                .cloned()
484                .collect::<Vec<_>>();
485            let execution_permit = policy_decision.issue_execution_permit(
486                &verification_case,
487                &check_plan,
488                &approval_records,
489            );
490            let scheduler_decision = schedule_check_plan(
491                &verification_case,
492                &check_plan,
493                BudgetLineage {
494                    budget_family: "forge-pilot.orchestration".into(),
495                    retry_family: attempt_id.clone(),
496                    queue_hop_count: 1,
497                    max_time_budget_ms: Some(self.config.time_budget_secs * 1000),
498                    remaining_time_budget_ms: Some(
499                        self.config
500                            .time_budget_secs
501                            .saturating_sub(started.elapsed().as_secs())
502                            * 1000,
503                    ),
504                    max_cost_budget_units: None,
505                    remaining_cost_budget_units: None,
506                    exhausted: budget_exhausted,
507                },
508                observation
509                    .degradations
510                    .iter()
511                    .map(|degradation| DegradationMarker {
512                        kind: degradation.kind.clone(),
513                        reason: degradation.detail.clone(),
514                        blocks_promotion: true,
515                    })
516                    .collect(),
517                vec![QueueHop {
518                    hop_index: 0,
519                    from_queue: "pilot.observe".into(),
520                    to_queue: "pilot.verify".into(),
521                    enqueued_at: iteration_started_at.clone(),
522                    dequeued_at: Some(iteration_timestamp.to_rfc3339()),
523                }],
524            );
525
526            let mut action_family = None;
527            let mut export_completed = false;
528            let mut import_completed = false;
529            let mut advisory_only = check_plan.advisory_only;
530            let mut outcome_signature = None::<String>;
531            let mut refuted = false;
532            let mut produced_artifact_refs = Vec::<String>::new();
533
534            if let Ok(permit) = execution_permit.as_ref() {
535                let action = execute_plan(
536                    &observation,
537                    &selected.stable_key,
538                    &selected.plan,
539                    permit,
540                    &self.config,
541                )
542                .await?;
543                action_family = Some(action.family);
544                advisory_only = action.advisory_only;
545                outcome_signature = Some(action.outcome_signature.clone());
546                totals.actions_executed += u32::from(!action.advisory_only);
547                totals.advisory_only_steps |= action.advisory_only;
548
549                if let Some(bundle) = &action.bundle {
550                    produced_artifact_refs.push(bundle.bundle_id.clone());
551                    let roundtrip = canonical_roundtrip(
552                        bundle,
553                        &self.config.scope.namespace,
554                        &self.resources.forge_store,
555                        &self.resources.memory_store,
556                    )
557                    .await?;
558                    totals.exports_completed += 1;
559                    totals.imports_completed +=
560                        u32::from(roundtrip.import_result.status == "complete");
561                    export_completed = true;
562                    import_completed = roundtrip.import_result.status == "complete";
563                }
564
565                self.history.record_outcome(
566                    &selected.stable_key,
567                    action
568                        .bundle
569                        .as_ref()
570                        .map(|bundle| bundle.bundle_id.clone()),
571                    action.outcome_signature.clone(),
572                    self.config.max_retries_per_target,
573                );
574
575                refuted = action
576                    .oracle_execution
577                    .as_ref()
578                    .and_then(|oracle| oracle.refutation.as_ref())
579                    .is_some_and(|refutation| {
580                        matches!(
581                            refutation.outcome,
582                            kernel_oracles::OracleRefutationOutcome::FlipWitness { .. }
583                        )
584                    });
585            } else {
586                if execution_permit
587                    .as_ref()
588                    .err()
589                    .is_some_and(permit_error_forces_advisory_only)
590                {
591                    action_family = Some(ActionFamily::AdvisoryOnly);
592                    advisory_only = true;
593                    totals.advisory_only_steps |= true;
594                }
595                self.history.record_outcome(
596                    &selected.stable_key,
597                    None,
598                    if advisory_only {
599                        "advisory_only_by_policy".into()
600                    } else {
601                        "blocked_by_policy".into()
602                    },
603                    self.config.max_retries_per_target,
604                );
605            }
606
607            let calibration_snapshot = CalibrationSnapshot::evaluate(
608                verification_case.case_id.clone(),
609                iteration_timestamp.to_rfc3339(),
610                observation
611                    .recent_imports
612                    .iter()
613                    .filter_map(|row| row.comparability_snapshot_version.clone())
614                    .collect::<std::collections::BTreeSet<_>>()
615                    .len()
616                    <= 1,
617                observation
618                    .explanation
619                    .as_ref()
620                    .map_or(true, |explanation| {
621                        explanation.calibration_caveats.is_empty()
622                    }),
623                self.config.calibration_abstention_threshold_micros,
624                if degraded {
625                    self.config.calibration_abstention_threshold_micros
626                } else {
627                    100_000
628                },
629                observation
630                    .degradations
631                    .iter()
632                    .map(|degradation| format!("{}:{}", degradation.kind, degradation.detail))
633                    .chain(
634                        observation
635                            .explanation
636                            .as_ref()
637                            .into_iter()
638                            .flat_map(|explanation| explanation.calibration_caveats.clone()),
639                    )
640                    .collect(),
641            );
642            if calibration_snapshot.forces_advisory_only {
643                advisory_only = true;
644                totals.advisory_only_steps |= true;
645            }
646            if advisory_only {
647                apply_advisory_plan_constraints(&mut check_plan);
648            }
649            let effective_degraded = degraded || advisory_only;
650
651            let verification_attempt = VerificationAttempt::completed(
652                verification_case.case_id.clone(),
653                check_plan.plan_id.clone(),
654                attempt_id.clone(),
655                Some(trial_id),
656                if execution_permit.is_err() && !advisory_only {
657                    VerificationAttemptState::Blocked
658                } else if advisory_only {
659                    VerificationAttemptState::AdvisoryOnly
660                } else {
661                    VerificationAttemptState::Succeeded
662                },
663                advisory_only,
664                effective_degraded,
665                iteration_started_at.clone(),
666                iteration_timestamp.to_rfc3339(),
667                outcome_signature.clone(),
668            );
669            let control_receipt = ControlReceipt::new_case_execution(
670                &verification_case,
671                &check_plan,
672                &verification_attempt,
673                false,
674                json!({
675                    "target_key": selected.stable_key.clone(),
676                    "action_family": action_family.as_ref().map(|family| format!("{:?}", family)),
677                    "export_completed": export_completed,
678                    "import_completed": import_completed,
679                    "policy_allowed": execution_permit.is_ok(),
680                    "scheduler_promotion_blocked": scheduler_decision.promotion_blocked,
681                }),
682            );
683            let adjudication = adjudicate_case(
684                &verification_case,
685                &check_plan,
686                &verification_attempt,
687                &control_receipt,
688                &policy_decision,
689                &calibration_snapshot,
690                refuted,
691                scheduler_decision.budget_lineage.exhausted,
692                currently_promoted(&observation, &selected),
693            );
694            let rollback_invalidation_count = if adjudication.rollback_plan.required {
695                execute_rollback_invalidation(
696                    &self.resources.memory_store,
697                    &verification_case,
698                    &adjudication,
699                )
700                .await?
701            } else {
702                None
703            };
704            let replayed = replay_case(&[
705                LedgerEntry::new(
706                    verification_case.case_id.clone(),
707                    1,
708                    LedgerEvent::CaseOpened {
709                        case: verification_case.clone(),
710                    },
711                ),
712                LedgerEntry::new(
713                    verification_case.case_id.clone(),
714                    2,
715                    LedgerEvent::PlanAdopted {
716                        plan: check_plan.clone(),
717                    },
718                ),
719                LedgerEntry::new(
720                    verification_case.case_id.clone(),
721                    3,
722                    LedgerEvent::AttemptRecorded {
723                        attempt: verification_attempt.clone(),
724                    },
725                ),
726                LedgerEntry::new(
727                    verification_case.case_id.clone(),
728                    4,
729                    LedgerEvent::ReceiptAppended {
730                        receipt: Box::new(control_receipt.clone()),
731                    },
732                ),
733                LedgerEntry::new(
734                    verification_case.case_id.clone(),
735                    5,
736                    LedgerEvent::CaseClosed {
737                        case_id: verification_case.case_id.clone(),
738                        disposition: adjudication.terminal_disposition.clone(),
739                    },
740                ),
741            ])
742            .map_err(PilotError::ControlPlaneReplay)?;
743
744            let lineage_receipt = build_lineage_receipt(
745                replayed.case.as_ref().unwrap_or(&verification_case),
746                &check_plan,
747                &verification_attempt,
748                &selected.stable_key,
749                outcome_signature
750                    .clone()
751                    .unwrap_or_else(|| "no_action".into()),
752                vec![
753                    selected.stable_key.clone(),
754                    selected.normalization.bounded_region_digest.clone(),
755                ],
756                produced_artifact_refs.clone(),
757                observation
758                    .degradations
759                    .iter()
760                    .map(|degradation| format!("{}:{}", degradation.kind, degradation.detail))
761                    .collect(),
762            );
763            let export_trace = build_export_trace(
764                replayed.case.as_ref().unwrap_or(&verification_case),
765                &check_plan,
766                &verification_attempt,
767                action_family,
768                export_completed,
769                import_completed,
770                produced_artifact_refs,
771            );
772            let retry_cap_reached = self.history.retry_count(&selected.stable_key)
773                >= self.config.max_retries_per_target;
774            let stop_rule_evaluation = build_stop_rule_evaluation(
775                if retry_cap_reached {
776                    "retry_cap_exceeded"
777                } else if advisory_only {
778                    "advisory_only"
779                } else if effective_degraded {
780                    "degraded_continue"
781                } else {
782                    "continue_or_complete"
783                },
784                retry_cap_reached,
785                self.config.cooldown_secs > 0 && iteration_index + 1 < self.config.max_iterations,
786                self.history.is_exhausted(&selected.stable_key),
787                effective_degraded,
788                advisory_only,
789            );
790            let execution_context = build_execution_context(
791                replayed.case.as_ref().unwrap_or(&verification_case),
792                &check_plan,
793                &verification_attempt,
794                &scheduler_decision,
795                effective_degraded,
796                advisory_only,
797            );
798            let verification_plan_artifact = build_verification_plan_artifact(
799                &check_plan,
800                {
801                    let mut blocked_checks: Vec<String> = decision
802                        .audit
803                        .as_ref()
804                        .map(|audit| audit.blocked_steps.as_slice())
805                        .unwrap_or(&[])
806                        .iter()
807                        .map(|blocked| format!("{:?}: {}", blocked.step_kind, blocked.reason))
808                        .collect();
809                    if check_plan.degraded {
810                        blocked_checks.push("plan degraded".into());
811                    }
812                    if advisory_only {
813                        blocked_checks
814                            .push("advisory-only path blocks promotion-bearing execution".into());
815                    }
816                    if calibration_snapshot.forces_advisory_only {
817                        blocked_checks.push("calibration forced advisory-only disposition".into());
818                    }
819                    blocked_checks
820                },
821                if execution_permit.is_ok() {
822                    Vec::new()
823                } else {
824                    let mut reasons = policy_decision.reasons.clone();
825                    if let Err(error) = &execution_permit {
826                        reasons.push(error.to_string());
827                    }
828                    reasons
829                },
830                if effective_degraded {
831                    vec!["runtime degraded".into()]
832                } else {
833                    Vec::new()
834                },
835            );
836            let mut repair_records = Vec::new();
837            if adjudication.rollback_plan.required || effective_degraded || advisory_only {
838                repair_records.push(build_repair_record(
839                    format!("repair:{}", verification_case.case_id),
840                    vec![selected.stable_key.clone()],
841                    if adjudication.rollback_plan.required {
842                        RepairClassV1::RollbackRepair
843                    } else {
844                        RepairClassV1::VerificationStateRepair
845                    },
846                    vec![control_receipt.receipt_id.to_string()],
847                    if adjudication.rollback_plan.required {
848                        "projection_scope".into()
849                    } else {
850                        "verification_state".into()
851                    },
852                    if adjudication.rollback_plan.required {
853                        "requires_recompute".into()
854                    } else {
855                        "replayable".into()
856                    },
857                    if adjudication.rollback_plan.required {
858                        format!(
859                            "rollback invalidated {} derived rows",
860                            rollback_invalidation_count.unwrap_or(0)
861                        )
862                    } else if advisory_only {
863                        "kept output advisory-only pending stronger verification".into()
864                    } else {
865                        "recorded degraded execution without mutating truth".into()
866                    },
867                    execution_context.clone(),
868                ));
869            }
870            if retry_cap_reached {
871                iterations.push(LoopIterationReport {
872                    schema_version: LOOP_ITERATION_REPORT_V1_SCHEMA.into(),
873                    iteration: iteration_index + 1,
874                    selected_target_key: Some(selected.stable_key),
875                    action_family,
876                    export_completed,
877                    import_completed,
878                    degraded,
879                    advisory_only,
880                    outcome_signature,
881                    verification_case: replayed.case,
882                    check_plan: Some(check_plan),
883                    verification_attempt: Some(verification_attempt),
884                    scheduler_decision: Some(scheduler_decision),
885                    policy_decision: Some(policy_decision),
886                    approval_records,
887                    calibration_snapshot: Some(calibration_snapshot),
888                    adjudication: Some(adjudication),
889                    control_receipt: Some(control_receipt),
890                    target_normalization: Some(selected.normalization),
891                    decision_audit: decision.audit,
892                    lineage_receipt: Some(lineage_receipt),
893                    export_trace: Some(export_trace),
894                    stop_rule_evaluation: Some(stop_rule_evaluation),
895                    verification_plan_artifact: Some(verification_plan_artifact),
896                    boundary_repairs: Vec::new(),
897                    repair_records,
898                    rollback_invalidation_count,
899                    #[cfg(feature = "governance")]
900                    governance_receipt: governance_gate_result.as_ref().map(|(_, r)| r.clone()),
901                });
902                return Ok(self.finish_report(
903                    started_at,
904                    trace_ctx.clone(),
905                    attempt_id.clone(),
906                    started,
907                    iterations,
908                    totals,
909                    HaltReason::RetryCapExceeded,
910                    Some(&*observation),
911                ));
912            }
913
914            iterations.push(LoopIterationReport {
915                schema_version: LOOP_ITERATION_REPORT_V1_SCHEMA.into(),
916                iteration: iteration_index + 1,
917                selected_target_key: Some(selected.stable_key),
918                action_family,
919                export_completed,
920                import_completed,
921                degraded: effective_degraded,
922                advisory_only,
923                outcome_signature,
924                verification_case: replayed.case,
925                check_plan: Some(check_plan),
926                verification_attempt: Some(verification_attempt),
927                scheduler_decision: Some(scheduler_decision),
928                policy_decision: Some(policy_decision),
929                approval_records,
930                calibration_snapshot: Some(calibration_snapshot),
931                adjudication: Some(adjudication),
932                control_receipt: Some(control_receipt),
933                target_normalization: Some(selected.normalization),
934                decision_audit: decision.audit,
935                lineage_receipt: Some(lineage_receipt),
936                export_trace: Some(export_trace),
937                stop_rule_evaluation: Some(stop_rule_evaluation),
938                verification_plan_artifact: Some(verification_plan_artifact),
939                boundary_repairs: Vec::new(),
940                repair_records,
941                rollback_invalidation_count,
942                #[cfg(feature = "governance")]
943                governance_receipt: governance_gate_result.as_ref().map(|(_, r)| r.clone()),
944            });
945
946            if advisory_only
947                && matches!(
948                    observation.status.disposition,
949                    ObservationDisposition::ImportRequired
950                )
951                && matches!(
952                    selected.plan,
953                    crate::act::PlanKind::AdvisoryOnlyVerificationPlan(_)
954                )
955            {
956                return Ok(self.finish_report(
957                    started_at,
958                    trace_ctx.clone(),
959                    attempt_id.clone(),
960                    started,
961                    iterations,
962                    totals,
963                    HaltReason::AdvisoryOnlyFallback,
964                    Some(&*observation),
965                ));
966            }
967
968            if self.config.cooldown_secs > 0 && iteration_index + 1 < self.config.max_iterations {
969                sleep(Duration::from_secs(self.config.cooldown_secs)).await;
970            }
971        }
972
973        Ok(self.finish_report(
974            started_at,
975            trace_ctx,
976            attempt_id,
977            started,
978            iterations,
979            totals,
980            HaltReason::MaxIterationsReached,
981            last_observation.as_deref(),
982        ))
983    }
984
985    #[allow(clippy::too_many_arguments)]
986    fn finish_report(
987        &self,
988        started_at: chrono::DateTime<chrono::Utc>,
989        trace_ctx: TraceCtx,
990        attempt_id: AttemptId,
991        started: Instant,
992        iterations: Vec<LoopIterationReport>,
993        totals: LoopTotals,
994        halt_reason: HaltReason,
995        observation: Option<&Observation>,
996    ) -> LoopReport {
997        let elapsed_seconds = started.elapsed().as_secs_f64();
998        let finished_at = chrono::Utc::now().to_rfc3339();
999        let receipt = LoopReceipt {
1000            schema_version: PILOT_LOOP_RECEIPT_V1_SCHEMA.into(),
1001            receipt_id: uuid::Uuid::new_v4().to_string(),
1002            trace_ctx,
1003            attempt_id,
1004            namespace: self.config.scope.namespace.clone(),
1005            workspace_path: self.config.workspace_path.clone(),
1006            observation_paths: observation.map(|observation| observation.paths.clone()),
1007            observation_status: observation.map(|observation| observation.status.clone()),
1008            non_authoritative: true,
1009            advisory_only: totals.advisory_only_steps,
1010            consumer_only_truth_access: true,
1011            budget: LoopBudgetReceipt {
1012                workload_class: "orchestration".into(),
1013                time_budget_secs: self.config.time_budget_secs,
1014                max_iterations: self.config.max_iterations,
1015                cooldown_secs: self.config.cooldown_secs,
1016                max_retries_per_target: self.config.max_retries_per_target,
1017            },
1018            halt_reason: halt_reason.clone(),
1019            iterations_completed: iterations.len() as u32,
1020            actions_executed: totals.actions_executed,
1021            exports_completed: totals.exports_completed,
1022            imports_completed: totals.imports_completed,
1023            degraded_iterations: totals.degraded_iterations,
1024            targets_investigated: totals.targets_investigated.clone(),
1025            started_at: started_at.to_rfc3339(),
1026            finished_at,
1027            elapsed_seconds,
1028        };
1029        let mut report = LoopReport {
1030            schema_version: LOOP_REPORT_V1_SCHEMA.into(),
1031            receipt,
1032            iterations_completed: iterations.len() as u32,
1033            actions_executed: totals.actions_executed,
1034            exports_completed: totals.exports_completed,
1035            imports_completed: totals.imports_completed,
1036            halt_reason,
1037            targets_investigated: totals.targets_investigated,
1038            degraded_iterations: totals.degraded_iterations,
1039            elapsed_seconds,
1040            advisory_only_steps: totals.advisory_only_steps,
1041            iterations,
1042        };
1043        apply_loop_report_boundary_repairs(&mut report);
1044        report
1045    }
1046}
1047
1048fn permit_error_forces_advisory_only(error: &verification_policy::PermitIssuanceError) -> bool {
1049    matches!(
1050        error,
1051        verification_policy::PermitIssuanceError::PlanNotPromotionCapable
1052            | verification_policy::PermitIssuanceError::PromotionBlocked
1053            | verification_policy::PermitIssuanceError::CitationIncomplete
1054            | verification_policy::PermitIssuanceError::ObligationRefsIncomplete
1055    )
1056}
1057
1058fn apply_advisory_plan_constraints(plan: &mut CheckPlan) {
1059    plan.advisory_only = true;
1060    plan.promotable_if_completed = false;
1061    plan.target_exactness = semantic_memory_forge::ExactnessLevelV1::Conservative;
1062    plan.evidence_admissibility = semantic_memory_forge::EvidenceAdmissibilityV1::Restricted;
1063    if plan.proof_obligations_remaining.is_empty() {
1064        plan.proof_obligations_remaining
1065            .push("promotion requires a non-advisory proof path".into());
1066    }
1067    if let Some(profile) = plan.proof_profile.as_mut() {
1068        profile.evidence_admissibility = plan.evidence_admissibility.clone();
1069        profile.proof_obligations_remaining = plan.proof_obligations_remaining.clone();
1070        profile.admissible_evidence =
1071            vec!["control_receipt".into(), "advisory_oracle_slice".into()];
1072    }
1073}