Skip to main content

forge_pilot/
loop_runner_report.rs

1//! Report and receipt types emitted by the OODA loop runner.
2//!
3//! These types are serialized as boundary artifacts and consumed by
4//! downstream verification surfaces and the CLI renderer.
5
6use crate::act::ActionFamily;
7use crate::observe::{ObservationPaths, ObservationStatus};
8use crate::types::{
9    DecisionAudit, ExecutionLineageReceipt, ExportActionTraceV1, RepairRecordV1,
10    StopRuleEvaluation, TargetNormalization, VerificationPlanArtifact,
11};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use serde_json::json;
15use stack_ids::{AttemptId, TraceCtx};
16use verification_adjudication::AdjudicationResult;
17use verification_calibration::CalibrationSnapshot;
18use verification_control::{
19    BoundaryArtifactKind, BoundaryRepairRecord, CheckPlan, ControlReceipt, SchedulerDecision,
20    VerificationAttempt, VerificationCase,
21};
22use verification_policy::{ApprovalRecord, PolicyDecision};
23
24/// Schema version string for the pilot loop receipt artifact.
25pub const PILOT_LOOP_RECEIPT_V1_SCHEMA: &str = "forge_pilot_loop_receipt_v1";
26/// Schema version string for a single loop iteration report artifact.
27pub const LOOP_ITERATION_REPORT_V1_SCHEMA: &str = "forge_pilot_loop_iteration_report_v1";
28/// Schema version string for the full loop report artifact.
29pub const LOOP_REPORT_V1_SCHEMA: &str = "forge_pilot_loop_report_v1";
30
31/// Reason the OODA loop terminated.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
33#[serde(rename_all = "snake_case")]
34pub enum HaltReason {
35    MaxIterationsReached,
36    TimeBudgetExhausted,
37    NothingWorthInvestigating,
38    ImportRequired,
39    NamespaceMismatch,
40    StorageUnavailable,
41    StorageCorrupt,
42    AllTargetsExhausted,
43    MissingKernelPayload,
44    ThinExportBlockedExecution,
45    ExternalHalt,
46    RetryCapExceeded,
47    AdvisoryOnlyFallback,
48    #[cfg(feature = "governance")]
49    GovernanceIncidentHalt,
50    #[cfg(feature = "governance")]
51    GovernanceAuthorityInsufficient,
52}
53
54/// Budget parameters that governed the loop run.
55#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
56pub struct LoopBudgetReceipt {
57    pub workload_class: String,
58    pub time_budget_secs: u64,
59    pub max_iterations: u32,
60    pub cooldown_secs: u64,
61    pub max_retries_per_target: u32,
62}
63
64/// Typed receipt summarizing a complete loop run for auditing and replay.
65#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
66pub struct LoopReceipt {
67    pub schema_version: String,
68    pub receipt_id: String,
69    pub trace_ctx: TraceCtx,
70    pub attempt_id: AttemptId,
71    pub namespace: String,
72    pub workspace_path: String,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub observation_paths: Option<ObservationPaths>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub observation_status: Option<ObservationStatus>,
77    pub non_authoritative: bool,
78    pub advisory_only: bool,
79    #[serde(default)]
80    pub consumer_only_truth_access: bool,
81    pub budget: LoopBudgetReceipt,
82    pub halt_reason: HaltReason,
83    pub iterations_completed: u32,
84    pub actions_executed: u32,
85    pub exports_completed: u32,
86    pub imports_completed: u32,
87    pub degraded_iterations: u32,
88    pub targets_investigated: Vec<String>,
89    pub started_at: String,
90    pub finished_at: String,
91    pub elapsed_seconds: f64,
92}
93
94/// Detailed report for a single OODA loop iteration.
95///
96/// Includes the verification case, check plan, attempt, policy decision,
97/// calibration, adjudication, and all produced receipts and artifacts.
98#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
99pub struct LoopIterationReport {
100    pub schema_version: String,
101    pub iteration: u32,
102    pub selected_target_key: Option<String>,
103    pub action_family: Option<ActionFamily>,
104    pub export_completed: bool,
105    pub import_completed: bool,
106    pub degraded: bool,
107    pub advisory_only: bool,
108    pub outcome_signature: Option<String>,
109    pub verification_case: Option<VerificationCase>,
110    pub check_plan: Option<CheckPlan>,
111    pub verification_attempt: Option<VerificationAttempt>,
112    pub scheduler_decision: Option<SchedulerDecision>,
113    pub policy_decision: Option<PolicyDecision>,
114    pub approval_records: Vec<ApprovalRecord>,
115    pub calibration_snapshot: Option<CalibrationSnapshot>,
116    pub adjudication: Option<AdjudicationResult>,
117    pub control_receipt: Option<ControlReceipt>,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub target_normalization: Option<TargetNormalization>,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub decision_audit: Option<DecisionAudit>,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub lineage_receipt: Option<ExecutionLineageReceipt>,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub export_trace: Option<ExportActionTraceV1>,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub stop_rule_evaluation: Option<StopRuleEvaluation>,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub verification_plan_artifact: Option<VerificationPlanArtifact>,
130    #[serde(default)]
131    pub boundary_repairs: Vec<BoundaryRepairRecord>,
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub repair_records: Vec<RepairRecordV1>,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub rollback_invalidation_count: Option<usize>,
136    #[cfg(feature = "governance")]
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub governance_receipt: Option<crate::governance_gate::GovernanceReceiptV1>,
139}
140
141/// Top-level report for a complete OODA loop run.
142///
143/// Contains the loop receipt, all iteration reports, and aggregate
144/// counters for actions, exports, imports, and degradations.
145#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
146pub struct LoopReport {
147    pub schema_version: String,
148    pub receipt: LoopReceipt,
149    pub iterations_completed: u32,
150    pub actions_executed: u32,
151    pub exports_completed: u32,
152    pub imports_completed: u32,
153    pub halt_reason: HaltReason,
154    pub targets_investigated: Vec<String>,
155    pub degraded_iterations: u32,
156    pub elapsed_seconds: f64,
157    pub advisory_only_steps: bool,
158    pub iterations: Vec<LoopIterationReport>,
159}
160
161pub(crate) fn apply_loop_report_boundary_repairs(report: &mut LoopReport) {
162    for iteration in &mut report.iterations {
163        if iteration.boundary_repairs.is_empty() && iteration.approval_records.is_empty() {
164            iteration.boundary_repairs.push(BoundaryRepairRecord::new(
165                BoundaryArtifactKind::LoopIterationReport,
166                LOOP_ITERATION_REPORT_V1_SCHEMA,
167                "default_empty_array",
168                "$.approval_records",
169                None,
170                json!([]),
171                "approval records default to an empty array at the loop report boundary",
172            ));
173        }
174    }
175}
176
177/// Parses a loop report from a boundary artifact payload.
178pub fn parse_loop_report_boundary(mut value: serde_json::Value) -> Result<LoopReport, String> {
179    let iterations = value
180        .get_mut("iterations")
181        .and_then(|iterations| iterations.as_array_mut())
182        .ok_or_else(|| {
183            "loop report boundary validation failed: missing iterations array".to_string()
184        })?;
185
186    for iteration in iterations {
187        let Some(iteration_object) = iteration.as_object_mut() else {
188            return Err(
189                "loop report boundary validation failed: iteration entry is not an object".into(),
190            );
191        };
192        if !iteration_object.contains_key("approval_records") {
193            iteration_object.insert("approval_records".into(), json!([]));
194        }
195        if !iteration_object.contains_key("boundary_repairs") {
196            iteration_object.insert("boundary_repairs".into(), json!([]));
197        }
198    }
199
200    let mut report: LoopReport = serde_json::from_value(value)
201        .map_err(|error| format!("loop report boundary validation failed: {error}"))?;
202    apply_loop_report_boundary_repairs(&mut report);
203    Ok(report)
204}