1use 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
57pub struct LoopRunnerResources {
59 pub runtime: KnowledgeRuntime,
60 pub memory_store: MemoryStore,
61 pub forge_store: ForgeStore,
62}
63
64impl LoopRunnerResources {
65 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#[derive(Debug, Clone, Default)]
85pub struct ExternalHaltFlag {
86 flag: Arc<AtomicBool>,
87}
88
89impl ExternalHaltFlag {
90 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
100pub 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 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 pub fn halt_flag(&self) -> ExternalHaltFlag {
130 self.external_halt.clone()
131 }
132
133 pub fn history(&self) -> &PilotHistory {
135 &self.history
136 }
137
138 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 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 #[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}