Skip to main content

ic_backup/policy/execution_progress/
mod.rs

1//! Pure original-plan progress from exact retained journals; no scheduling or dispatch.
2
3use crate::model::{
4    artifacts::ArtifactChecksumRecord,
5    attempt_journal::{
6        AttemptAuthorityRecord, AttemptJournalRecord, AttemptJournalView, OperationBindingRecord,
7    },
8    effect_graph::MAX_EFFECT_OPERATIONS,
9    operation_plan::{OperationPlanRecord, PlanContextRecord, PlannedOperationRecord},
10};
11use serde::Serialize;
12use std::collections::{BTreeMap, BTreeSet};
13use thiserror::Error;
14
15/// Passive complete journal set supplied by the local evidence-owning caller.
16#[derive(Clone, Debug)]
17pub struct ExecutionProgressRequest<'a> {
18    /// Original immutable declaration; its hash binds every journal's intent.
19    pub plan: &'a OperationPlanRecord,
20    /// Exactly one validated retained journal per declared operation, in any order.
21    pub journals: &'a [&'a AttemptJournalRecord],
22}
23
24/// Derived local attempt condition, without fresh permission or backend qualification.
25#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
26#[serde(rename_all = "snake_case")]
27pub enum OperationProgressState {
28    /// At least one declared prerequisite lacks retained Applied evidence.
29    AwaitingDependencies,
30    /// Prerequisites are Applied and original mutation allowance remains.
31    MutationAvailable,
32    /// No unresolved mutation or Applied receipt, and mutation allowance is exhausted.
33    MutationExhausted,
34    /// A mutation is unresolved and original reconciliation allowance remains.
35    MutationUnresolved,
36    /// An observation reservation remains unresolved, even if its allowance is exhausted.
37    ObservationUnresolved,
38    /// A mutation remains unresolved with no pending observation or remaining observation allowance.
39    ReconciliationExhausted,
40    /// This operation has retained integration-qualified Applied evidence.
41    Applied,
42}
43
44/// Read-only local progress for one exact operation in graph planning order.
45#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
46pub struct OperationProgressView {
47    /// Original opaque operation identity, never an array index.
48    pub operation_sequence: u64,
49    /// Derived local condition; none of these values authorize dispatch.
50    pub state: OperationProgressState,
51    /// Exact retained accounting, authority digest and unresolved attempt identities.
52    pub attempts: AttemptJournalView,
53}
54
55/// Sums over original assigned allowances only; unused plan headroom is excluded.
56#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
57pub struct ExecutionAttemptTotalsView {
58    /// Mutation reservations consumed across all original journals, without refunds.
59    pub mutations_used: u32,
60    /// Observation reservations consumed across all original journals, without refunds.
61    pub observations_used: u32,
62    /// Sum of remaining originally assigned mutation allowances.
63    pub mutations_remaining: u32,
64    /// Sum of remaining originally assigned observation allowances.
65    pub observations_remaining: u32,
66}
67
68/// Read-only projection bound to the complete original declaration and retained journals.
69///
70/// Applied evidence is caller-qualified and does not prove full run completion,
71/// current IC state, artifact durability, application safety or reference release.
72#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
73pub struct ExecutionProgressView {
74    /// Full canonical original plan intent.
75    pub intent: ArtifactChecksumRecord,
76    /// Original explicit dependency graph digest.
77    pub graph: ArtifactChecksumRecord,
78    /// Number of operations with retained Applied receipts, not a terminal proof.
79    pub applied_operations: usize,
80    /// Derived totals over the original per-operation ceilings.
81    pub attempts: ExecutionAttemptTotalsView,
82    /// Exact journal conditions in deterministic graph planning order.
83    pub operations: Vec<OperationProgressView>,
84}
85
86/// Admit exact complete journal coverage and project causal retained local progress.
87///
88/// Missing journals are errors, never fresh zero-consumption declarations. Any
89/// attempted operation requires Applied evidence for every declared prerequisite.
90/// This validates retained causality, not cross-journal dispatch chronology: no
91/// remote observations, authority probes, writes or scheduling occur.
92///
93/// # Errors
94/// Rejects excessive, duplicate, unknown, missing or mismatched journals, premature
95/// attempts and accounting overflow. Unresolved/exhausted states remain visible.
96pub fn progress(
97    request: &ExecutionProgressRequest<'_>,
98) -> Result<ExecutionProgressView, ExecutionProgressError> {
99    if request.journals.len() > MAX_EFFECT_OPERATIONS {
100        return Err(ExecutionProgressError::TooManyJournals);
101    }
102    let plan = request.plan;
103    let intent = plan.digest();
104    let mut journals = BTreeMap::new();
105    for journal in request.journals {
106        let sequence = journal.authority().binding().operation_sequence();
107        let operation = plan
108            .operation(sequence)
109            .map_err(|_| ExecutionProgressError::UnknownOperation(sequence))?;
110        if journals.contains_key(&sequence) {
111            return Err(ExecutionProgressError::DuplicateJournal(sequence));
112        }
113        let original = OriginalJournalBinding {
114            intent: &intent,
115            context: plan.context(),
116            operation,
117        };
118        if !original.matches(journal.authority()) {
119            return Err(ExecutionProgressError::AuthorityMismatch(sequence));
120        }
121        journals.insert(sequence, journal.view());
122    }
123    for operation in plan.operations() {
124        if !journals.contains_key(&operation.operation_sequence()) {
125            return Err(ExecutionProgressError::MissingJournal(
126                operation.operation_sequence(),
127            ));
128        }
129    }
130    let applied: BTreeSet<_> = journals
131        .iter()
132        .filter_map(|(sequence, attempts)| attempts.applied.then_some(*sequence))
133        .collect();
134    let mut totals = ExecutionAttemptTotalsView {
135        mutations_used: 0,
136        observations_used: 0,
137        mutations_remaining: 0,
138        observations_remaining: 0,
139    };
140    let mut operations = Vec::with_capacity(journals.len());
141    for node in plan.graph().ordered_nodes() {
142        let sequence = node.operation_sequence();
143        // Closed graph/table identity and complete coverage were admitted above.
144        let attempts = journals
145            .remove(&sequence)
146            .ok_or(ExecutionProgressError::MissingJournal(sequence))?;
147        let unmet = node
148            .depends_on()
149            .iter()
150            .find(|dependency| !applied.contains(dependency));
151        if attempts.mutations_used != 0
152            && let Some(prerequisite) = unmet
153        {
154            return Err(ExecutionProgressError::PrematureAttempt {
155                operation_sequence: sequence,
156                prerequisite: *prerequisite,
157            });
158        }
159        totals.add(&attempts)?;
160        operations.push(OperationProgressView {
161            operation_sequence: sequence,
162            state: condition(&attempts, unmet.is_some()),
163            attempts,
164        });
165    }
166    Ok(ExecutionProgressView {
167        intent,
168        graph: plan.graph().digest(),
169        applied_operations: applied.len(),
170        attempts: totals,
171        operations,
172    })
173}
174
175struct OriginalJournalBinding<'a> {
176    intent: &'a ArtifactChecksumRecord,
177    context: &'a PlanContextRecord,
178    operation: &'a PlannedOperationRecord,
179}
180impl OriginalJournalBinding<'_> {
181    fn matches(&self, authority: &AttemptAuthorityRecord) -> bool {
182        self.identity_matches(authority.binding())
183            && self.context_matches(authority.binding())
184            && authority.budget() == self.operation.budget()
185    }
186    fn identity_matches(&self, binding: &OperationBindingRecord) -> bool {
187        binding.intent() == self.intent.hash()
188            && binding.operation_sequence() == self.operation.operation_sequence()
189            && binding.target() == self.operation.target()
190            && binding.request() == self.operation.request()
191    }
192    fn context_matches(&self, binding: &OperationBindingRecord) -> bool {
193        binding.network() == self.context.network()
194            && binding.caller() == self.context.caller()
195            && binding.release() == self.context.release()
196    }
197}
198impl ExecutionAttemptTotalsView {
199    fn add(&mut self, attempts: &AttemptJournalView) -> Result<(), ExecutionProgressError> {
200        self.mutations_used = sum(self.mutations_used, attempts.mutations_used)?;
201        self.observations_used = sum(self.observations_used, attempts.observations_used)?;
202        self.mutations_remaining = sum(self.mutations_remaining, attempts.mutations_remaining)?;
203        self.observations_remaining =
204            sum(self.observations_remaining, attempts.observations_remaining)?;
205        Ok(())
206    }
207}
208fn sum(left: u32, right: u32) -> Result<u32, ExecutionProgressError> {
209    left.checked_add(right)
210        .ok_or(ExecutionProgressError::AccountingOverflow)
211}
212fn condition(attempts: &AttemptJournalView, unmet_dependencies: bool) -> OperationProgressState {
213    if attempts.applied {
214        OperationProgressState::Applied
215    } else if attempts.pending_observation.is_some() {
216        OperationProgressState::ObservationUnresolved
217    } else if attempts.pending_mutation.is_some() {
218        if attempts.observations_remaining == 0 {
219            OperationProgressState::ReconciliationExhausted
220        } else {
221            OperationProgressState::MutationUnresolved
222        }
223    } else if unmet_dependencies {
224        OperationProgressState::AwaitingDependencies
225    } else if attempts.mutations_remaining == 0 {
226        OperationProgressState::MutationExhausted
227    } else {
228        OperationProgressState::MutationAvailable
229    }
230}
231
232/// Typed original-plan journal admission, retained causality or accounting failure.
233#[derive(Debug, Error, Eq, PartialEq)]
234pub enum ExecutionProgressError {
235    /// The journal input exceeds the original bounded operation count.
236    #[error("execution progress exceeds {MAX_EFFECT_OPERATIONS} journals")]
237    TooManyJournals,
238    /// Multiple supplied journals claim the same original operation identity.
239    #[error("duplicate journal for operation {0}")]
240    DuplicateJournal(u64),
241    /// A supplied journal's operation is absent from the original plan.
242    #[error("journal operation {0} is absent from the original plan")]
243    UnknownOperation(u64),
244    /// Retained evidence for an original operation was not supplied.
245    #[error("missing original journal for operation {0}")]
246    MissingJournal(u64),
247    /// Intent, context, target, request or original allowance differs from the plan.
248    #[error("original journal authority mismatch for operation {0}")]
249    AuthorityMismatch(u64),
250    /// A reservation exists while a declared prerequisite lacks Applied evidence.
251    #[error(
252        "operation {operation_sequence} was attempted without applied prerequisite {prerequisite}"
253    )]
254    PrematureAttempt {
255        /// Operation with retained consumed mutation allowance.
256        operation_sequence: u64,
257        /// Exact declared prerequisite lacking retained Applied evidence.
258        prerequisite: u64,
259    },
260    /// Derived accounting could not be represented without overflow.
261    #[error("execution attempt accounting overflow")]
262    AccountingOverflow,
263}
264
265#[cfg(test)]
266mod tests;