Skip to main content

ic_backup/model/ic_observation/
mod.rs

1//! Exact already reserved IC recovery observations and bounded passive replies.
2
3use crate::model::{
4    artifacts::ArtifactChecksumRecord,
5    attempt_journal::{AttemptAuthorityRecord, AttemptJournalRecord, MAX_OPERATION_ATTEMPTS},
6    ic_lifecycle_reply::MAX_IC_LIFECYCLE_REPLY_BYTES,
7    ic_request::{IcManagementRequestRecord, IcRequestError},
8    ic_snapshot_reply::MAX_IC_SNAPSHOT_REPLY_BYTES,
9    operation_plan::{OperationPlanError, OperationPlanRecord, PlanContextRecord},
10};
11use std::fmt;
12use thiserror::Error;
13
14/// Raw observation reply bound derived from both existing method-specific codecs.
15pub const MAX_IC_OBSERVATION_REPLY_BYTES: usize =
16    if MAX_IC_SNAPSHOT_REPLY_BYTES < MAX_IC_LIFECYCLE_REPLY_BYTES {
17        MAX_IC_SNAPSHOT_REPLY_BYTES
18    } else {
19        MAX_IC_LIFECYCLE_REPLY_BYTES
20    };
21
22/// Structural binding to an original pending mutation and its reserved IC observation.
23///
24/// Retain original observation bytes before reserving their existing payload digest.
25/// This request neither spends nor proves dispatch custody, fresh read permission,
26/// authentication or effect attribution. Recovery never permits repeating a lost call.
27#[derive(Debug)]
28pub struct IcObservationRequest<'a> {
29    plan: &'a OperationPlanRecord,
30    mutation: &'a IcManagementRequestRecord,
31    payload: &'a IcManagementRequestRecord,
32    reservation: ObservationReservation,
33}
34
35impl<'a> IcObservationRequest<'a> {
36    /// Bind original mutation bytes and exact already reserved status/list bytes.
37    ///
38    /// Both payloads must name the same original target. Observation identity reuses
39    /// the existing IC payload digest; no second hash or journal owner is introduced.
40    /// # Errors
41    /// Rejects changed authority/payloads or missing original pending reservations.
42    pub fn new(
43        plan: &'a OperationPlanRecord,
44        operation_sequence: u64,
45        journal: &AttemptJournalRecord,
46        mutation: &'a IcManagementRequestRecord,
47        payload: &'a IcManagementRequestRecord,
48    ) -> Result<Self, IcObservationRequestError> {
49        let authority = plan.attempt_authority(operation_sequence)?;
50        if journal.authority() != &authority {
51            return Err(IcObservationRequestError::AuthorityMismatch);
52        }
53        mutation.validate_mutation_binding(authority.binding())?;
54        payload.validate_observation_binding(authority.binding(), &payload.digest())?;
55        let reservation = ObservationReservation::new(authority, journal, payload.digest())?;
56        let request = Self {
57            plan,
58            mutation,
59            payload,
60            reservation,
61        };
62        Ok(request)
63    }
64    /// Read the full original context, inventory and selection.
65    #[must_use]
66    pub const fn plan(&self) -> &OperationPlanRecord {
67        self.plan
68    }
69    /// Read the exact original mutation, never an executable retry.
70    #[must_use]
71    pub const fn mutation(&self) -> &'a IcManagementRequestRecord {
72        self.mutation
73    }
74    /// Read exact observation receiver, routing target, method and Candid bytes.
75    #[must_use]
76    pub const fn payload(&self) -> &'a IcManagementRequestRecord {
77        self.payload
78    }
79    /// Read the original operation authority and immutable attempt limits.
80    #[must_use]
81    pub const fn authority(&self) -> &AttemptAuthorityRecord {
82        &self.reservation.authority
83    }
84    /// Read the original unresolved mutation attempt.
85    #[must_use]
86    pub const fn mutation_attempt(&self) -> u32 {
87        self.reservation.mutation_attempt
88    }
89    /// Read the original already consumed observation attempt.
90    #[must_use]
91    pub const fn observation_attempt(&self) -> u32 {
92        self.reservation.observation_attempt
93    }
94    /// Recheck current exact reservations and original observation bytes without IO.
95    /// # Errors
96    /// Rejects changed authority, settled/replaced attempts or different reserved bytes.
97    pub fn validate_journal(
98        &self,
99        journal: &AttemptJournalRecord,
100    ) -> Result<(), IcObservationRequestError> {
101        self.reservation.validate(journal)
102    }
103}
104
105#[derive(Debug)]
106pub(crate) struct ObservationReservation {
107    pub authority: AttemptAuthorityRecord,
108    pub mutation_attempt: u32,
109    pub observation_attempt: u32,
110    pub request: ArtifactChecksumRecord,
111}
112
113/// Read only the already consumed original attempt IDs, in mutation-first order.
114fn pending_attempts(
115    journal: &AttemptJournalRecord,
116) -> Result<(u32, u32), IcObservationRequestError> {
117    Ok((
118        journal
119            .pending_mutation()
120            .ok_or(IcObservationRequestError::NoPendingMutation)?,
121        journal
122            .pending_observation()
123            .ok_or(IcObservationRequestError::NoPendingObservation)?,
124    ))
125}
126
127impl ObservationReservation {
128    pub(crate) fn new(
129        authority: AttemptAuthorityRecord,
130        journal: &AttemptJournalRecord,
131        request: ArtifactChecksumRecord,
132    ) -> Result<Self, IcObservationRequestError> {
133        let (mutation_attempt, observation_attempt) = pending_attempts(journal)?;
134        let reservation = Self {
135            authority,
136            mutation_attempt,
137            observation_attempt,
138            request,
139        };
140        reservation.validate(journal)?;
141        Ok(reservation)
142    }
143
144    pub(crate) fn validate(
145        &self,
146        journal: &AttemptJournalRecord,
147    ) -> Result<(), IcObservationRequestError> {
148        if journal.authority() != &self.authority {
149            return Err(IcObservationRequestError::AuthorityMismatch);
150        }
151        if journal.pending_mutation() != Some(self.mutation_attempt) {
152            return Err(IcObservationRequestError::MutationMismatch);
153        }
154        if journal.pending_observation() != Some(self.observation_attempt) {
155            return Err(IcObservationRequestError::ObservationMismatch);
156        }
157        if journal.pending_observation_request() != Some(self.request.hash()) {
158            return Err(IcObservationRequestError::RequestMismatch);
159        }
160        Ok(())
161    }
162}
163
164/// Passive provider fields; actual authentication and observation timing remain external.
165#[derive(Clone)]
166pub struct IcObservationResponseInput {
167    /// Exact full original authority digest including immutable allowances.
168    pub authority: ArtifactChecksumRecord,
169    /// Exact original unresolved mutation attempt.
170    pub mutation_attempt: u32,
171    /// Exact already reserved observation attempt, not another call number.
172    pub observation_attempt: u32,
173    /// Exact existing observation payload digest retained in its reservation.
174    pub request: ArtifactChecksumRecord,
175    /// Actual network/caller/release association claimed by the provider.
176    pub context: PlanContextRecord,
177    /// Actual response target claimed by the provider; canonicalized on admission.
178    pub target: String,
179    /// Exact bounded raw Candid reply.
180    pub reply: Vec<u8>,
181    /// Opaque retained association/authentication/timing evidence; not self-authenticating.
182    pub evidence: ArtifactChecksumRecord,
183}
184
185/// Immutable bounded passive observation, with no outcome, Default or Serde admission.
186#[derive(Clone)]
187pub struct IcObservationResponse {
188    input: IcObservationResponseInput,
189}
190impl IcObservationResponse {
191    /// Admit canonical target, chronological finite attempt IDs and bounded raw bytes.
192    /// # Errors
193    /// Rejects zero/excessive/reversed attempts, oversized replies and invalid principals.
194    pub fn new(mut input: IcObservationResponseInput) -> Result<Self, IcObservationResponseError> {
195        validate_response_input(&mut input, MAX_IC_OBSERVATION_REPLY_BYTES)?;
196        Ok(Self { input })
197    }
198    /// Read immutable canonical claims and exact raw bytes; no mutation access.
199    #[must_use]
200    pub const fn input(&self) -> &IcObservationResponseInput {
201        &self.input
202    }
203}
204impl fmt::Debug for IcObservationResponse {
205    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
206        fmt_response_input(&self.input, formatter, "IcObservationResponse")
207    }
208}
209
210pub(crate) fn validate_response_input(
211    input: &mut IcObservationResponseInput,
212    max_reply_bytes: usize,
213) -> Result<(), IcObservationResponseError> {
214    if input.mutation_attempt == 0
215        || input.observation_attempt <= input.mutation_attempt
216        || input.observation_attempt > MAX_OPERATION_ATTEMPTS
217    {
218        return Err(IcObservationResponseError::InvalidAttempts);
219    }
220    if input.reply.len() > max_reply_bytes {
221        return Err(IcObservationResponseError::ReplyTooLarge);
222    }
223    input.target = crate::model::principal::canonical_text(&input.target)
224        .ok_or(IcObservationResponseError::InvalidTarget)?;
225    Ok(())
226}
227
228pub(crate) fn fmt_response_input(
229    input: &IcObservationResponseInput,
230    formatter: &mut fmt::Formatter<'_>,
231    name: &str,
232) -> fmt::Result {
233    formatter
234        .debug_struct(name)
235        .field("authority", &input.authority)
236        .field("mutation_attempt", &input.mutation_attempt)
237        .field("observation_attempt", &input.observation_attempt)
238        .field("request", &input.request)
239        .field("target", &input.target)
240        .field("reply_bytes", &input.reply.len())
241        .finish_non_exhaustive()
242}
243
244/// Structural original observation denial; no spending or effect outcome changes.
245#[derive(Debug, Error)]
246pub enum IcObservationRequestError {
247    /// Journal differs from original plan/operation/allowances.
248    #[error("IC observation original authority mismatch")]
249    AuthorityMismatch,
250    /// Original mutation has no pending reservation.
251    #[error("IC observation requires a pending original mutation")]
252    NoPendingMutation,
253    /// Original recovery observation has no pending reservation.
254    #[error("IC observation requires a pending original observation")]
255    NoPendingObservation,
256    /// Current original mutation changed or settled.
257    #[error("IC observation original mutation mismatch")]
258    MutationMismatch,
259    /// Current original observation changed or settled.
260    #[error("IC observation original attempt mismatch")]
261    ObservationMismatch,
262    /// Reserved observation digest differs from exact canonical payload bytes.
263    #[error("IC observation reserved request mismatch")]
264    RequestMismatch,
265    /// Original mutation or observation target/digest/class differs.
266    #[error(transparent)]
267    Payload(#[from] IcRequestError),
268    /// Original plan cannot derive the requested operation authority.
269    #[error(transparent)]
270    Plan(#[from] OperationPlanError),
271}
272
273/// Bounded passive response rejection with no raw bytes in diagnostics.
274#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
275pub enum IcObservationResponseError {
276    /// IDs must be chronological within the existing 1,024 total-attempt bound.
277    #[error("invalid IC observation response attempts")]
278    InvalidAttempts,
279    /// Raw bytes exceed the existing finite codec ceilings.
280    #[error("IC observation response reply too large")]
281    ReplyTooLarge,
282    /// Actual target is not a principal.
283    #[error("invalid IC observation response target")]
284    InvalidTarget,
285}
286
287mod capture_settlement;
288pub use capture_settlement::{IcCaptureAttribution, IcCaptureSettlement};
289
290mod settlement;
291pub use settlement::{IcLifecycleAttribution, IcLifecycleSettlement};
292
293#[cfg(test)]
294mod tests;