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