Skip to main content

ic_backup/model/attempt_journal/authority/
mod.rs

1//! Immutable operation identity and finite attempt allowance; no fresh authority.
2
3use super::AttemptJournalRecordError;
4use crate::model::artifacts::{ArtifactChecksumRecord, canonical_hash};
5use serde::{Deserialize, Serialize};
6
7/// Maximum total mutation and reconciliation-observation attempts per journal.
8pub const MAX_OPERATION_ATTEMPTS: u32 = 1024;
9
10/// Passive operation identity selected by the integration before effects.
11#[derive(Clone, Debug)]
12pub struct OperationBindingRequest {
13    /// Canonical immutable plan/intent digest supplied by its owner.
14    pub intent: String,
15    /// Exact operation sequence within that intent.
16    pub operation_sequence: u64,
17    /// Qualified network identity fingerprint; an endpoint label is insufficient.
18    pub network: String,
19    /// Exact selected caller principal; credentials are never retained here.
20    pub caller: String,
21    /// Exact physical canister principal selected for this operation.
22    pub target: String,
23    /// Opaque exact release evidence digest supplied by the integration.
24    pub release: String,
25    /// Canonical exact mutating request digest supplied by its owning codec.
26    pub request: String,
27}
28
29/// Canonical immutable declared operation identity, not observed live authority.
30#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
31#[serde(try_from = "BindingFields")]
32pub struct OperationBindingRecord {
33    intent: String,
34    operation_sequence: u64,
35    network: String,
36    caller: String,
37    target: String,
38    release: String,
39    request: String,
40}
41
42#[derive(Deserialize)]
43#[serde(deny_unknown_fields)]
44struct BindingFields {
45    intent: String,
46    operation_sequence: u64,
47    network: String,
48    caller: String,
49    target: String,
50    release: String,
51    request: String,
52}
53
54impl TryFrom<BindingFields> for OperationBindingRecord {
55    type Error = AttemptJournalRecordError;
56    fn try_from(fields: BindingFields) -> Result<Self, Self::Error> {
57        Self::new(&OperationBindingRequest {
58            intent: fields.intent,
59            operation_sequence: fields.operation_sequence,
60            network: fields.network,
61            caller: fields.caller,
62            target: fields.target,
63            release: fields.release,
64            request: fields.request,
65        })
66    }
67}
68
69impl OperationBindingRecord {
70    /// Canonicalize exact declared identities without IO or authorizing dispatch.
71    ///
72    /// # Errors
73    /// Rejects malformed principal text or SHA-256 digest fields.
74    pub fn new(request: &OperationBindingRequest) -> Result<Self, AttemptJournalRecordError> {
75        Ok(Self {
76            intent: canonical_hash(&request.intent)?,
77            operation_sequence: request.operation_sequence,
78            network: canonical_hash(&request.network)?,
79            caller: crate::model::principal::canonical_text(&request.caller)
80                .ok_or(AttemptJournalRecordError::InvalidPrincipal)?,
81            target: crate::model::principal::canonical_text(&request.target)
82                .ok_or(AttemptJournalRecordError::InvalidPrincipal)?,
83            release: canonical_hash(&request.release)?,
84            request: canonical_hash(&request.request)?,
85        })
86    }
87    /// Read immutable intent identity.
88    #[must_use]
89    pub fn intent(&self) -> &str {
90        &self.intent
91    }
92    /// Read the exact operation sequence.
93    #[must_use]
94    pub const fn operation_sequence(&self) -> u64 {
95        self.operation_sequence
96    }
97    /// Read the exact declared network fingerprint.
98    #[must_use]
99    pub fn network(&self) -> &str {
100        &self.network
101    }
102    /// Read the canonical selected caller principal.
103    #[must_use]
104    pub fn caller(&self) -> &str {
105        &self.caller
106    }
107    /// Read the canonical selected physical target principal.
108    #[must_use]
109    pub fn target(&self) -> &str {
110        &self.target
111    }
112    /// Read the integration-owned release digest.
113    #[must_use]
114    pub fn release(&self) -> &str {
115        &self.release
116    }
117    /// Read the exact canonical mutating request digest.
118    #[must_use]
119    pub fn request(&self) -> &str {
120        &self.request
121    }
122}
123
124/// Immutable finite allowances; consumption never refunds or replenishes these limits.
125#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
126#[serde(try_from = "BudgetFields")]
127pub struct AttemptBudgetRecord {
128    mutations: u32,
129    observations: u32,
130}
131
132#[derive(Deserialize)]
133#[serde(deny_unknown_fields)]
134struct BudgetFields {
135    mutations: u32,
136    observations: u32,
137}
138
139impl TryFrom<BudgetFields> for AttemptBudgetRecord {
140    type Error = AttemptJournalRecordError;
141    fn try_from(fields: BudgetFields) -> Result<Self, Self::Error> {
142        Self::new(fields.mutations, fields.observations)
143    }
144}
145
146impl AttemptBudgetRecord {
147    /// Admit finite independent mutation and observation allowances, including zero.
148    ///
149    /// # Errors
150    /// Rejects overflow or a total greater than [`MAX_OPERATION_ATTEMPTS`].
151    pub fn new(mutations: u32, observations: u32) -> Result<Self, AttemptJournalRecordError> {
152        if mutations
153            .checked_add(observations)
154            .is_none_or(|total| total > MAX_OPERATION_ATTEMPTS)
155        {
156            return Err(AttemptJournalRecordError::BudgetTooLarge);
157        }
158        Ok(Self {
159            mutations,
160            observations,
161        })
162    }
163    /// Read the immutable mutation-attempt ceiling.
164    #[must_use]
165    pub const fn mutations(&self) -> u32 {
166        self.mutations
167    }
168    /// Read the immutable reconciliation-observation ceiling.
169    #[must_use]
170    pub const fn observations(&self) -> u32 {
171        self.observations
172    }
173}
174
175/// Named exact declared operation and budget admitted by a journal owner.
176///
177/// This data alone is not fresh permission, settlement evidence or a dispatch permit.
178#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
179#[serde(deny_unknown_fields)]
180pub struct AttemptAuthorityRecord {
181    binding: OperationBindingRecord,
182    budget: AttemptBudgetRecord,
183}
184
185impl AttemptAuthorityRecord {
186    /// Bind already validated operation identity to immutable finite limits.
187    #[must_use]
188    pub const fn new(binding: OperationBindingRecord, budget: AttemptBudgetRecord) -> Self {
189        Self { binding, budget }
190    }
191    /// Read canonical declared identity.
192    #[must_use]
193    pub const fn binding(&self) -> &OperationBindingRecord {
194        &self.binding
195    }
196    /// Read immutable ceilings.
197    #[must_use]
198    pub const fn budget(&self) -> &AttemptBudgetRecord {
199        &self.budget
200    }
201    /// Hash exact immutable authority using the maintained domain-separated encoding.
202    ///
203    /// Encoding is the ASCII domain (including its NUL), four fixed 64-byte
204    /// lowercase digest strings in intent/network/release/request order, big-endian
205    /// u64 operation sequence, u8-length-prefixed canonical ASCII caller/target
206    /// text, then big-endian u32 mutation/observation limits. Mutable events are excluded.
207    #[must_use]
208    pub fn digest(&self) -> ArtifactChecksumRecord {
209        let mut bytes = b"ic-backup/attempt-authority/v1\0".to_vec();
210        for digest in [
211            &self.binding.intent,
212            &self.binding.network,
213            &self.binding.release,
214            &self.binding.request,
215        ] {
216            bytes.extend_from_slice(digest.as_bytes());
217        }
218        bytes.extend_from_slice(&self.binding.operation_sequence.to_be_bytes());
219        for principal in [&self.binding.caller, &self.binding.target] {
220            // Admission bounds canonical ASCII text to at most 63 bytes.
221            bytes.push(principal.len().to_le_bytes()[0]);
222            bytes.extend_from_slice(principal.as_bytes());
223        }
224        bytes.extend_from_slice(&self.budget.mutations.to_be_bytes());
225        bytes.extend_from_slice(&self.budget.observations.to_be_bytes());
226        ArtifactChecksumRecord::from_bytes(&bytes)
227    }
228}