Skip to main content

ic_backup/model/attempt_journal/
mod.rs

1//! Durable finite local attempt accounting adapted from Canic's pending/receipt contracts.
2
3mod authority;
4mod history;
5pub use authority::{
6    AttemptAuthorityRecord, AttemptBudgetRecord, MAX_OPERATION_ATTEMPTS, OperationBindingRecord,
7    OperationBindingRequest,
8};
9
10use crate::model::{
11    artifacts::{ArtifactChecksumRecord, ChecksumError, canonical_hash},
12    bounded_sequence,
13};
14use history::{AttemptEventRecord, Projection};
15use serde::{Deserialize, Deserializer, Serialize};
16use thiserror::Error;
17
18/// Maximum retained reservation/receipt events in one operation journal.
19pub const MAX_ATTEMPT_EVENTS: usize = 2048;
20/// Maximum encoded input and canonical output bytes admitted by persistence ops.
21pub const MAX_ATTEMPT_JOURNAL_BYTES: u64 = 1024 * 1024;
22
23/// Integration-qualified direct mutation outcome; an invocation failure is insufficient.
24#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
25#[serde(rename_all = "snake_case")]
26pub enum MutationOutcomeRecord {
27    /// Exact requested effect is qualified as applied.
28    Applied,
29    /// Exact requested effect is qualified as not applied; consumed allowance remains spent.
30    NotApplied,
31}
32
33/// Integration-qualified observation outcome for its exact reserved mutation.
34#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
35#[serde(rename_all = "snake_case")]
36pub enum ObservationOutcomeRecord {
37    /// Observation qualifies exact requested effect as applied.
38    Applied,
39    /// Observation qualifies exact requested effect as not applied.
40    NotApplied,
41    /// Settled observation cannot resolve the mutation; no allowance is restored.
42    Uncertain,
43}
44
45/// Passive exact direct-reply evidence supplied by its qualified owner.
46#[derive(Clone, Debug)]
47pub struct MutationReceiptRequest {
48    /// Previously reserved mutation attempt number.
49    pub attempt: u32,
50    /// Exact mutating request digest, checked against immutable authority.
51    pub request: String,
52    /// Qualified effect outcome; local process exit alone cannot produce it.
53    pub outcome: MutationOutcomeRecord,
54    /// Canonical digest of retained owner evidence.
55    pub evidence: String,
56}
57
58/// Passive exact reconciliation-reply evidence supplied by its qualified owner.
59///
60/// The owner qualifies observation completion, custody and paid-effect settlement.
61/// A lost observation reply stays pending; it cannot be recorded as `Uncertain`
62/// merely because the local invocation failed or its response was lost.
63#[derive(Clone, Debug)]
64pub struct ObservationReceiptRequest {
65    /// Previously reserved observation attempt number.
66    pub attempt: u32,
67    /// Exact observation request digest, checked against its reservation.
68    pub request: String,
69    /// Qualified outcome, including explicit unresolved evidence.
70    pub outcome: ObservationOutcomeRecord,
71    /// Canonical digest of retained owner evidence.
72    pub evidence: String,
73}
74
75/// Read-only derived local accounting; does not schedule or authorize effects.
76#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
77pub struct AttemptJournalView {
78    /// Exact immutable authority digest including original budgets.
79    pub authority: ArtifactChecksumRecord,
80    /// Mutation attempts already consumed, without refunds.
81    pub mutations_used: u32,
82    /// Reconciliation observations already consumed, without refunds.
83    pub observations_used: u32,
84    /// Original mutation allowance still unconsumed.
85    pub mutations_remaining: u32,
86    /// Original observation allowance still unconsumed.
87    pub observations_remaining: u32,
88    /// Exact unresolved mutation attempt, if any.
89    pub pending_mutation: Option<u32>,
90    /// Exact unresolved observation attempt, if any.
91    pub pending_observation: Option<u32>,
92    /// Retained qualified application of this operation, not full run completion.
93    pub applied: bool,
94    /// Number of retained reservation and receipt events.
95    pub history_len: usize,
96}
97
98/// Exact locally retained evidence for an Applied operation.
99///
100/// The integration owns the evidence's meaning and qualification. In particular,
101/// observation evidence is not the original mutation reply or retry authority.
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
103pub enum AppliedReceiptEvidence<'a> {
104    /// The original mutation was settled by its direct qualified receipt.
105    Mutation {
106        /// Original mutation attempt number.
107        attempt: u32,
108        /// Digest of the retained integration-qualified direct evidence.
109        evidence: &'a str,
110    },
111    /// A separately budgeted observation settled the original mutation.
112    Observation {
113        /// Separately reserved observation attempt number.
114        attempt: u32,
115        /// Exact observation request digest from its reservation.
116        request: &'a str,
117        /// Digest of retained integration-qualified observation evidence.
118        evidence: &'a str,
119    },
120}
121
122/// Maintained v1 append-only local reservations and receipts for one exact operation.
123#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
124#[serde(try_from = "JournalFields")]
125pub struct AttemptJournalRecord {
126    version: u16,
127    authority: AttemptAuthorityRecord,
128    events: Vec<AttemptEventRecord>,
129    #[serde(skip)]
130    projection: Projection,
131}
132
133#[derive(Deserialize)]
134#[serde(deny_unknown_fields)]
135struct JournalFields {
136    version: u16,
137    authority: AttemptAuthorityRecord,
138    #[serde(deserialize_with = "bounded_events")]
139    events: Vec<AttemptEventRecord>,
140}
141
142impl TryFrom<JournalFields> for AttemptJournalRecord {
143    type Error = AttemptJournalRecordError;
144    fn try_from(mut fields: JournalFields) -> Result<Self, Self::Error> {
145        if fields.version != 1 {
146            return Err(AttemptJournalRecordError::UnsupportedVersion(
147                fields.version,
148            ));
149        }
150        let mut projection = Projection::empty();
151        for event in &mut fields.events {
152            event.normalize()?;
153            projection.apply(event, fields.authority.budget())?;
154        }
155        Ok(Self {
156            version: 1,
157            authority: fields.authority,
158            events: fields.events,
159            projection,
160        })
161    }
162}
163
164impl AttemptJournalRecord {
165    /// Retain an exact validated authority with no consumed attempts.
166    #[must_use]
167    pub const fn new(authority: AttemptAuthorityRecord) -> Self {
168        Self {
169            version: 1,
170            authority,
171            events: Vec::new(),
172            projection: Projection::empty(),
173        }
174    }
175    /// Read exact declared identity and immutable ceilings.
176    #[must_use]
177    pub const fn authority(&self) -> &AttemptAuthorityRecord {
178        &self.authority
179    }
180    /// Read the exact canonical request of the unresolved observation, if any.
181    ///
182    /// This projects existing validated history. It creates no reservation or
183    /// repeat-dispatch permission and returns None after a settled observation.
184    #[must_use]
185    pub fn pending_observation_request(&self) -> Option<&str> {
186        self.projection
187            .pending_observation
188            .as_ref()
189            .map(|pending| pending.request.as_str())
190    }
191    /// Read the terminal Applied receipt from validated original history.
192    ///
193    /// This does not authenticate evidence or grant dispatch, retry or release.
194    #[must_use]
195    pub fn applied_receipt(&self) -> Option<AppliedReceiptEvidence<'_>> {
196        match self.events.last()? {
197            AttemptEventRecord::MutationResolved {
198                mutation,
199                outcome: MutationOutcomeRecord::Applied,
200                evidence,
201            } => Some(AppliedReceiptEvidence::Mutation {
202                attempt: *mutation,
203                evidence,
204            }),
205            AttemptEventRecord::ObservationRecorded {
206                observation,
207                request,
208                outcome: ObservationOutcomeRecord::Applied,
209                evidence,
210            } => Some(AppliedReceiptEvidence::Observation {
211                attempt: *observation,
212                request,
213                evidence,
214            }),
215            _ => None,
216        }
217    }
218    /// Project local evidence without IO or replenishing authority.
219    ///
220    /// Use `Self::applied_receipt` for the terminal receipt or `Self::digest`
221    /// for exact full-history identity.
222    #[must_use]
223    pub fn view(&self) -> AttemptJournalView {
224        AttemptJournalView {
225            authority: self.authority.digest(),
226            mutations_used: self.projection.mutations_used,
227            observations_used: self.projection.observations_used,
228            mutations_remaining: self.authority.budget().mutations()
229                - self.projection.mutations_used,
230            observations_remaining: self.authority.budget().observations()
231                - self.projection.observations_used,
232            pending_mutation: self.projection.pending_mutation,
233            pending_observation: self
234                .projection
235                .pending_observation
236                .as_ref()
237                .map(|pending| pending.attempt),
238            applied: self.projection.applied,
239            history_len: self.events.len(),
240        }
241    }
242    /// Hash full original authority and every canonical chronological reservation/receipt.
243    ///
244    /// The NUL-terminated v1 domain precedes 64 ASCII authority hash bytes, a
245    /// big-endian u64 event count and closed tagged event bytes. Derived projections,
246    /// JSON formatting and filesystem paths are excluded. This authenticates no receipt.
247    #[must_use]
248    pub fn digest(&self) -> ArtifactChecksumRecord {
249        let mut bytes = b"ic-backup/attempt-journal-history/v1\0".to_vec();
250        bytes.extend_from_slice(self.authority.digest().hash().as_bytes());
251        bytes.extend_from_slice(&(self.events.len() as u64).to_be_bytes());
252        for event in &self.events {
253            event.append_digest_bytes(&mut bytes);
254        }
255        ArtifactChecksumRecord::from_bytes(&bytes)
256    }
257    pub(crate) fn reserve_mutation(&mut self) -> Result<u32, AttemptJournalRecordError> {
258        let attempt = self.projection.next_attempt;
259        self.append(AttemptEventRecord::MutationReserved { attempt })?;
260        Ok(attempt)
261    }
262    pub(crate) fn reserve_observation(
263        &mut self,
264        mutation: u32,
265        request: &str,
266    ) -> Result<u32, AttemptJournalRecordError> {
267        let attempt = self.projection.next_attempt;
268        self.append(AttemptEventRecord::ObservationReserved {
269            attempt,
270            mutation,
271            request: request.to_owned(),
272        })?;
273        Ok(attempt)
274    }
275    pub(crate) fn record_mutation(
276        &mut self,
277        receipt: MutationReceiptRequest,
278    ) -> Result<(), AttemptJournalRecordError> {
279        if canonical_hash(&receipt.request)? != self.authority.binding().request() {
280            return Err(AttemptJournalRecordError::RequestMismatch);
281        }
282        self.append(AttemptEventRecord::MutationResolved {
283            mutation: receipt.attempt,
284            outcome: receipt.outcome,
285            evidence: receipt.evidence,
286        })
287    }
288    pub(crate) fn record_observation(
289        &mut self,
290        receipt: ObservationReceiptRequest,
291    ) -> Result<(), AttemptJournalRecordError> {
292        self.append(AttemptEventRecord::ObservationRecorded {
293            observation: receipt.attempt,
294            request: receipt.request,
295            outcome: receipt.outcome,
296            evidence: receipt.evidence,
297        })
298    }
299    fn append(&mut self, mut event: AttemptEventRecord) -> Result<(), AttemptJournalRecordError> {
300        if self.events.len() == MAX_ATTEMPT_EVENTS {
301            return Err(AttemptJournalRecordError::HistoryTooLarge);
302        }
303        event.normalize()?;
304        let mut projection = self.projection.clone();
305        projection.apply(&event, self.authority.budget())?;
306        self.events.push(event);
307        self.projection = projection;
308        Ok(())
309    }
310}
311
312fn bounded_events<'de, D: Deserializer<'de>>(
313    deserializer: D,
314) -> Result<Vec<AttemptEventRecord>, D::Error> {
315    bounded_sequence::deserialize::<_, _, MAX_ATTEMPT_EVENTS>(
316        deserializer,
317        "a bounded chronological attempt event list",
318        AttemptJournalRecordError::HistoryTooLarge,
319    )
320}
321
322/// Typed exact identity, finite accounting or chronological state rejection.
323#[derive(Debug, Error)]
324pub enum AttemptJournalRecordError {
325    /// Other product generations are not maintained.
326    #[error("unsupported attempt journal version {0}")]
327    UnsupportedVersion(u16),
328    /// Original limits overflow or exceed the bounded total.
329    #[error("attempt allowance exceeds {MAX_OPERATION_ATTEMPTS}")]
330    BudgetTooLarge,
331    /// The chronological metadata count exceeds its finite bound.
332    #[error("attempt history exceeds {MAX_ATTEMPT_EVENTS} events")]
333    HistoryTooLarge,
334    /// A selected principal is not admitted by its owning boundary.
335    #[error("invalid attempt operation principal")]
336    InvalidPrincipal,
337    /// A digest fails exact canonical admission.
338    #[error(transparent)]
339    Checksum(#[from] ChecksumError),
340    /// An unresolved paid mutation blocks another mutation.
341    #[error("mutation attempt {attempt} remains unresolved")]
342    MutationPending {
343        /// Retained unresolved attempt number.
344        attempt: u32,
345    },
346    /// An observation is still unsettled locally.
347    #[error("observation attempt {attempt} remains unresolved")]
348    ObservationPending {
349        /// Retained unresolved attempt number.
350        attempt: u32,
351    },
352    /// A receipt or reconciliation has no corresponding unresolved mutation.
353    #[error("no pending mutation attempt")]
354    NoPendingMutation,
355    /// A reply has no corresponding unresolved observation.
356    #[error("no pending observation attempt")]
357    NoPendingObservation,
358    /// A receipt or sequence targets another attempt.
359    #[error("attempt identity mismatch: expected {expected}, actual {actual}")]
360    AttemptMismatch {
361        /// Expected exact attempt number.
362        expected: u32,
363        /// Rejected attempt number.
364        actual: u32,
365    },
366    /// Reply/request identity differs from its retained exact reservation.
367    #[error("attempt request digest mismatch")]
368    RequestMismatch,
369    /// Original mutation authority has been consumed.
370    #[error("mutation attempt allowance exhausted")]
371    MutationBudgetExhausted,
372    /// Original observation authority has been consumed.
373    #[error("observation attempt allowance exhausted")]
374    ObservationBudgetExhausted,
375    /// This exact operation is already qualified as applied.
376    #[error("operation application already retained")]
377    AlreadyApplied,
378}
379
380#[cfg(test)]
381mod tests;