Skip to main content

canic_core/storage/stable/
intent.rs

1//! Stable-memory intent store primitives.
2//!
3//! Data-only storage slots for cross-canister intent tracking. The ops layer
4//! enforces mechanical invariants (uniqueness, monotonic state transitions,
5//! aggregate consistency). Policy and capacity decisions live above this layer.
6
7use crate::cdk::structures::btreemap::BTreeMap as StableBtreeMap;
8use crate::{
9    cdk::structures::{
10        DefaultMemoryImpl, Memory, Storable, cell::Cell, memory::VirtualMemory, storable::Bound,
11    },
12    ids::{IntentId, IntentResourceKey},
13    model::{
14        intent::{PayloadBinding, ReceiptBackedIntent, ReceiptBackedIntentState},
15        replay::OperationId,
16    },
17    role_contract::allocation::memory::intent::{
18        APPLICATION_RECEIPT_ELIGIBILITY_ID, APPLICATION_RECEIPT_REPLAY_ID, INTENT_EXPIRY_INDEX_ID,
19        INTENT_META_ID, INTENT_PENDING_ID, INTENT_RECORDS_ID, INTENT_TOTALS_ID,
20        PLACEMENT_ACKNOWLEDGEMENT_INDEX_ID, RECEIPT_BACKED_INTENT_RECORDS_ID,
21    },
22    storage::prelude::*,
23};
24use std::{borrow::Cow, cell::RefCell};
25
26//
27// INTENT STORE
28//
29
30pub const INTENT_STORE_SCHEMA_VERSION: u32 = 1;
31pub const APPLICATION_RECEIPT_REPLAY_SCHEMA_VERSION: u32 = 1;
32pub const APPLICATION_RECEIPT_ELIGIBILITY_SCHEMA_VERSION: u32 = 1;
33const WASM_PAGE_BYTES: u64 = 65_536;
34const APPLICATION_RECEIPT_ELIGIBILITY_MIN_NODE_ENTRIES: u64 = 5;
35const APPLICATION_RECEIPT_ELIGIBILITY_CHUNK_BYTES: u64 = 2_378;
36const APPLICATION_RECEIPT_ELIGIBILITY_FIXED_BYTES: u64 = 116;
37
38type StableIntentMemory = VirtualMemory<DefaultMemoryImpl>;
39type ApplicationReceiptEligibilityMap = StableBtreeMap<
40    ApplicationReceiptEligibilityKeyRecord,
41    ApplicationReceiptEligibilityRecord,
42    StableIntentMemory,
43>;
44type ApplicationReceiptEligibilityState = (ApplicationReceiptEligibilityMap, StableIntentMemory);
45
46eager_static! {
47    static INTENT_META: RefCell<Cell<IntentStoreMetaRecord, VirtualMemory<DefaultMemoryImpl>>> =
48        RefCell::new(Cell::init(
49            crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_meta.v1", ty = IntentStoreMetaRecord, id = INTENT_META_ID),
50            IntentStoreMetaRecord::default(),
51        ));
52}
53
54eager_static! {
55    static APPLICATION_RECEIPT_REPLAY: RefCell<
56        StableBtreeMap<
57            OperationId,
58            ApplicationReceiptReplayRecord,
59            VirtualMemory<DefaultMemoryImpl>,
60        >
61    > = RefCell::new(StableBtreeMap::init(crate::ic_memory_key!(
62        authority = CANIC_CORE_MEMORY_AUTHORITY,
63        key = "canic.core.application_receipt_replay.v1",
64        ty = ApplicationReceiptReplayRecord,
65        id = APPLICATION_RECEIPT_REPLAY_ID
66    )));
67}
68
69eager_static! {
70    static APPLICATION_RECEIPT_ELIGIBILITY: RefCell<ApplicationReceiptEligibilityState> = {
71        let memory = crate::ic_memory_key!(
72            authority = CANIC_CORE_MEMORY_AUTHORITY,
73            key = "canic.core.application_receipt_eligibility.v1",
74            ty = ApplicationReceiptEligibilityRecord,
75            id = APPLICATION_RECEIPT_ELIGIBILITY_ID
76        );
77        let map = StableBtreeMap::init(memory.clone());
78        RefCell::new((map, memory))
79    };
80}
81
82eager_static! {
83    static RECEIPT_BACKED_INTENT_RECORDS: RefCell<
84        StableBtreeMap<
85            OperationId,
86            ReceiptBackedIntentRecord,
87            VirtualMemory<DefaultMemoryImpl>,
88        >
89    > = RefCell::new(StableBtreeMap::init(crate::ic_memory_key!(
90        authority = CANIC_CORE_MEMORY_AUTHORITY,
91        key = "canic.core.receipt_backed_intent_records.v1",
92        ty = ReceiptBackedIntentRecord,
93        id = RECEIPT_BACKED_INTENT_RECORDS_ID
94    )));
95}
96
97eager_static! {
98    static INTENT_EXPIRY_INDEX: RefCell<
99        StableBtreeMap<IntentExpiryKeyRecord, IntentExpiryEntryRecord, VirtualMemory<DefaultMemoryImpl>>
100    > = RefCell::new(
101        StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_expiry_index.v1", ty = IntentExpiryEntryRecord, id = INTENT_EXPIRY_INDEX_ID)),
102    );
103}
104
105eager_static! {
106    static PLACEMENT_ACKNOWLEDGEMENT_INDEX: RefCell<
107        StableBtreeMap<
108            OperationId,
109            PlacementAcknowledgementEntryRecord,
110            VirtualMemory<DefaultMemoryImpl>,
111        >
112    > = RefCell::new(StableBtreeMap::init(crate::ic_memory_key!(
113        authority = CANIC_CORE_MEMORY_AUTHORITY,
114        key = "canic.core.placement_acknowledgement_index.v1",
115        ty = PlacementAcknowledgementEntryRecord,
116        id = PLACEMENT_ACKNOWLEDGEMENT_INDEX_ID
117    )));
118}
119
120eager_static! {
121    static INTENT_RECORDS: RefCell<
122        StableBtreeMap<IntentId, IntentRecord, VirtualMemory<DefaultMemoryImpl>>
123    > = RefCell::new(
124        StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_records.v1", ty = IntentRecord, id = INTENT_RECORDS_ID)),
125    );
126}
127
128eager_static! {
129    static INTENT_TOTALS: RefCell<
130        StableBtreeMap<IntentResourceKey, IntentResourceTotalsRecord, VirtualMemory<DefaultMemoryImpl>>
131    > = RefCell::new(
132        StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_totals.v1", ty = IntentResourceTotalsRecord, id = INTENT_TOTALS_ID)),
133    );
134}
135
136eager_static! {
137    static INTENT_PENDING: RefCell<
138        StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>
139    > = RefCell::new(
140        StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_pending.v1", ty = IntentPendingEntryRecord, id = INTENT_PENDING_ID)),
141    );
142}
143
144impl Storable for IntentId {
145    const BOUND: Bound = Bound::Bounded {
146        max_size: 8,
147        is_fixed_size: true,
148    };
149
150    fn to_bytes(&self) -> Cow<'_, [u8]> {
151        Cow::Owned(self.0.to_be_bytes().to_vec())
152    }
153
154    fn into_bytes(self) -> Vec<u8> {
155        self.0.to_be_bytes().to_vec()
156    }
157
158    /// Decode the exact fixed-width stable intent identity.
159    ///
160    /// # Panics
161    ///
162    /// Panics when stable memory contains an intent ID that is not exactly eight bytes.
163    fn from_bytes(bytes: Cow<[u8]>) -> Self {
164        let bytes = <[u8; 8]>::try_from(bytes.as_ref()).unwrap_or_else(|_| {
165            panic!(
166                "stable IntentId is {} bytes; expected 8",
167                bytes.as_ref().len()
168            )
169        });
170
171        Self(u64::from_be_bytes(bytes))
172    }
173}
174
175impl Storable for OperationId {
176    const BOUND: Bound = Bound::Bounded {
177        max_size: 32,
178        is_fixed_size: true,
179    };
180
181    fn to_bytes(&self) -> Cow<'_, [u8]> {
182        Cow::Borrowed(self.as_bytes())
183    }
184
185    fn into_bytes(self) -> Vec<u8> {
186        self.as_bytes().to_vec()
187    }
188
189    /// Decode the exact fixed-width stable operation identity.
190    ///
191    /// # Panics
192    ///
193    /// Panics when stable memory contains an operation ID that is not exactly 32 bytes.
194    fn from_bytes(bytes: Cow<[u8]>) -> Self {
195        let operation_id = <[u8; 32]>::try_from(bytes.as_ref()).unwrap_or_else(|_| {
196            panic!(
197                "stable OperationId is {} bytes; expected 32",
198                bytes.as_ref().len()
199            )
200        });
201        Self::from_bytes(operation_id)
202    }
203}
204
205/// Ordered stable key for one finite local-intent cleanup deadline.
206#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
207pub struct IntentExpiryKeyRecord {
208    pub due_at_secs: u64,
209    pub intent_id: IntentId,
210}
211
212impl Storable for IntentExpiryKeyRecord {
213    const BOUND: Bound = Bound::Bounded {
214        max_size: 16,
215        is_fixed_size: true,
216    };
217
218    fn to_bytes(&self) -> Cow<'_, [u8]> {
219        Cow::Owned(self.into_bytes())
220    }
221
222    fn into_bytes(self) -> Vec<u8> {
223        let mut bytes = Vec::with_capacity(16);
224        bytes.extend_from_slice(&self.due_at_secs.to_be_bytes());
225        bytes.extend_from_slice(&self.intent_id.0.to_be_bytes());
226        bytes
227    }
228
229    /// Decode the exact fixed-width stable intent-expiry key.
230    ///
231    /// # Panics
232    ///
233    /// Panics when stable memory contains a key that is not exactly sixteen bytes.
234    fn from_bytes(bytes: Cow<[u8]>) -> Self {
235        let bytes = <[u8; 16]>::try_from(bytes.as_ref()).unwrap_or_else(|_| {
236            panic!(
237                "stable IntentExpiryKeyRecord is {} bytes; expected 16",
238                bytes.as_ref().len()
239            )
240        });
241        let (due_at_secs, intent_id) = bytes.split_at(8);
242        Self {
243            due_at_secs: u64::from_be_bytes(
244                due_at_secs.try_into().expect("expiry key deadline width"),
245            ),
246            intent_id: IntentId(u64::from_be_bytes(
247                intent_id.try_into().expect("expiry key intent width"),
248            )),
249        }
250    }
251}
252
253/// Stable value for one finite local-intent cleanup deadline.
254#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
255pub struct IntentExpiryEntryRecord {
256    pub intent_id: IntentId,
257}
258
259impl IntentExpiryEntryRecord {
260    pub const STATE_CONTRACT_NAME: &'static str = "IntentExpiryEntryRecord";
261    pub const STORABLE_MAX_SIZE: u32 = 32;
262}
263
264impl_storable_bounded!(
265    IntentExpiryEntryRecord,
266    IntentExpiryEntryRecord::STORABLE_MAX_SIZE,
267    false
268);
269
270///
271/// IntentState
272///
273
274#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
275pub enum IntentState {
276    Pending,
277    Committed,
278    Aborted,
279}
280
281///
282/// IntentRecord
283///
284
285#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
286pub struct IntentRecord {
287    pub id: IntentId,
288    pub resource_key: IntentResourceKey,
289    pub quantity: u64,
290    pub state: IntentState,
291    pub created_at: u64,
292    // TTL is enforced logically at read time; the derived index schedules cleanup.
293    pub ttl_secs: Option<u64>,
294}
295
296impl IntentRecord {
297    pub const STATE_CONTRACT_NAME: &'static str = "IntentRecord";
298    pub const STORABLE_MAX_SIZE: u32 = 229;
299}
300
301impl_storable_bounded!(IntentRecord, IntentRecord::STORABLE_MAX_SIZE, false);
302
303///
304/// IntentStoreMetaRecord
305///
306
307#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
308pub struct IntentStoreMetaRecord {
309    pub schema_version: u32,
310    pub next_intent_id: IntentId,
311    pub pending_total: u64,
312    pub committed_total: u64,
313    pub aborted_total: u64,
314}
315
316impl IntentStoreMetaRecord {
317    pub const STATE_CONTRACT_NAME: &'static str = "IntentStoreMetaRecord";
318    pub const STORABLE_MAX_SIZE: u32 = 96;
319}
320
321impl Default for IntentStoreMetaRecord {
322    fn default() -> Self {
323        Self {
324            schema_version: INTENT_STORE_SCHEMA_VERSION,
325            next_intent_id: IntentId(1),
326            pending_total: 0,
327            committed_total: 0,
328            aborted_total: 0,
329        }
330    }
331}
332
333impl_storable_bounded!(
334    IntentStoreMetaRecord,
335    IntentStoreMetaRecord::STORABLE_MAX_SIZE,
336    false
337);
338
339///
340/// IntentResourceTotalsRecord
341///
342
343#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
344pub struct IntentResourceTotalsRecord {
345    pub reserved_qty: u64,
346    pub committed_qty: u64,
347    pub pending_count: u64,
348}
349
350impl IntentResourceTotalsRecord {
351    pub const STATE_CONTRACT_NAME: &'static str = "IntentResourceTotalsRecord";
352    pub const STORABLE_MAX_SIZE: u32 = 69;
353}
354
355impl_storable_bounded!(
356    IntentResourceTotalsRecord,
357    IntentResourceTotalsRecord::STORABLE_MAX_SIZE,
358    false
359);
360
361///
362/// IntentPendingEntryRecord
363///
364
365#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
366pub struct IntentPendingEntryRecord {
367    pub resource_key: IntentResourceKey,
368    pub quantity: u64,
369    pub created_at: u64,
370    // TTL is enforced logically at read time; cleanup is asynchronous.
371    pub ttl_secs: Option<u64>,
372}
373
374impl IntentPendingEntryRecord {
375    pub const STATE_CONTRACT_NAME: &'static str = "IntentPendingEntryRecord";
376    pub const STORABLE_MAX_SIZE: u32 = 224;
377}
378
379impl_storable_bounded!(
380    IntentPendingEntryRecord,
381    IntentPendingEntryRecord::STORABLE_MAX_SIZE,
382    false
383);
384
385/// Stable representation of one durable receipt-backed intent.
386#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
387pub struct ReceiptBackedIntentRecord {
388    pub schema_version: u32,
389    pub operation_id: OperationId,
390    pub payload_binding: PayloadBinding,
391    pub resource_key: IntentResourceKey,
392    pub quantity: u64,
393    pub state: ReceiptBackedIntentState,
394    pub revision: u64,
395    pub created_at_ns: u64,
396    pub updated_at_ns: u64,
397}
398
399impl ReceiptBackedIntentRecord {
400    pub const STATE_CONTRACT_NAME: &'static str = "ReceiptBackedIntentRecord";
401    pub const STORABLE_MAX_SIZE: u32 = 1024;
402
403    #[must_use]
404    pub fn into_intent(self) -> ReceiptBackedIntent {
405        ReceiptBackedIntent {
406            schema_version: self.schema_version,
407            operation_id: self.operation_id,
408            payload_binding: self.payload_binding,
409            resource_key: self.resource_key,
410            quantity: self.quantity,
411            state: self.state,
412            revision: self.revision,
413            created_at_ns: self.created_at_ns,
414            updated_at_ns: self.updated_at_ns,
415        }
416    }
417}
418
419impl_storable_bounded!(
420    ReceiptBackedIntentRecord,
421    ReceiptBackedIntentRecord::STORABLE_MAX_SIZE,
422    false
423);
424
425/// Stable replay deadline for one application-owned receipt-backed intent.
426#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
427pub struct ApplicationReceiptReplayRecord {
428    pub schema_version: u32,
429    pub operation_id: OperationId,
430    pub replay_deadline_ns: u64,
431}
432
433impl ApplicationReceiptReplayRecord {
434    pub const STATE_CONTRACT_NAME: &'static str = "ApplicationReceiptReplayRecord";
435    pub const STORABLE_MAX_SIZE: u32 = 124;
436}
437
438impl_storable_bounded!(
439    ApplicationReceiptReplayRecord,
440    ApplicationReceiptReplayRecord::STORABLE_MAX_SIZE,
441    false
442);
443
444/// Ordered stable key for one application receipt terminal-retention deadline.
445#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
446pub struct ApplicationReceiptEligibilityKeyRecord {
447    pub eligible_at_ns: u64,
448    pub operation_id: OperationId,
449}
450
451impl Storable for ApplicationReceiptEligibilityKeyRecord {
452    const BOUND: Bound = Bound::Bounded {
453        max_size: 40,
454        is_fixed_size: true,
455    };
456
457    fn to_bytes(&self) -> Cow<'_, [u8]> {
458        Cow::Owned(self.into_bytes())
459    }
460
461    fn into_bytes(self) -> Vec<u8> {
462        let mut bytes = Vec::with_capacity(40);
463        bytes.extend_from_slice(&self.eligible_at_ns.to_be_bytes());
464        bytes.extend_from_slice(self.operation_id.as_bytes());
465        bytes
466    }
467
468    /// Decode the exact fixed-width stable eligibility key.
469    ///
470    /// # Panics
471    ///
472    /// Panics when stable memory contains a key that is not exactly forty bytes.
473    fn from_bytes(bytes: Cow<[u8]>) -> Self {
474        let bytes = <[u8; 40]>::try_from(bytes.as_ref()).unwrap_or_else(|_| {
475            panic!(
476                "stable ApplicationReceiptEligibilityKeyRecord is {} bytes; expected 40",
477                bytes.as_ref().len()
478            )
479        });
480        let (eligible_at_ns, operation_id) = bytes.split_at(8);
481        Self {
482            eligible_at_ns: u64::from_be_bytes(
483                eligible_at_ns
484                    .try_into()
485                    .expect("eligibility deadline width"),
486            ),
487            operation_id: OperationId::from_bytes(
488                operation_id
489                    .try_into()
490                    .expect("eligibility operation identity width"),
491            ),
492        }
493    }
494}
495
496/// Exact immutable binding for one application terminal eligibility entry.
497#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
498pub struct ApplicationReceiptEligibilityRecord {
499    pub schema_version: u32,
500    pub operation_id: OperationId,
501    pub payload_binding: PayloadBinding,
502    pub terminal_revision: u64,
503}
504
505impl ApplicationReceiptEligibilityRecord {
506    pub const STATE_CONTRACT_NAME: &'static str = "ApplicationReceiptEligibilityRecord";
507    pub const STORABLE_MAX_SIZE: u32 = 229;
508}
509
510impl_storable_bounded!(
511    ApplicationReceiptEligibilityRecord,
512    ApplicationReceiptEligibilityRecord::STORABLE_MAX_SIZE,
513    false
514);
515
516/// Stable value proving that an exact placement operation is queued for root acknowledgement.
517#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
518pub struct PlacementAcknowledgementEntryRecord {
519    pub operation_id: OperationId,
520}
521
522impl PlacementAcknowledgementEntryRecord {
523    pub const STATE_CONTRACT_NAME: &'static str = "PlacementAcknowledgementEntryRecord";
524    pub const STORABLE_MAX_SIZE: u32 = 128;
525}
526
527impl_storable_bounded!(
528    PlacementAcknowledgementEntryRecord,
529    PlacementAcknowledgementEntryRecord::STORABLE_MAX_SIZE,
530    false
531);
532
533///
534/// IntentMetaData
535///
536/// Canonical intent-store metadata allocation snapshot.
537///
538
539#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
540pub struct IntentMetaData {
541    pub record: IntentStoreMetaRecord,
542}
543
544impl IntentMetaData {
545    pub const STATE_CONTRACT_NAME: &'static str = "IntentMetaData";
546}
547
548///
549/// IntentRecordEntryRecord
550///
551/// One logical intent-record snapshot row preserving its stable intent ID key.
552///
553
554#[derive(Clone, Debug, Eq, PartialEq)]
555pub struct IntentRecordEntryRecord {
556    pub intent_id: IntentId,
557    pub record: IntentRecord,
558}
559
560///
561/// IntentRecordsData
562///
563/// Canonical intent-records allocation snapshot.
564///
565
566#[derive(Clone, Debug, Default, Eq, PartialEq)]
567pub struct IntentRecordsData {
568    pub entries: Vec<IntentRecordEntryRecord>,
569}
570
571impl IntentRecordsData {
572    pub const STATE_CONTRACT_NAME: &'static str = "IntentRecordsData";
573}
574
575///
576/// IntentTotalsEntryRecord
577///
578/// One logical intent-total snapshot row preserving its stable resource key.
579///
580
581#[derive(Clone, Debug, Eq, PartialEq)]
582pub struct IntentTotalsEntryRecord {
583    pub resource_key: IntentResourceKey,
584    pub record: IntentResourceTotalsRecord,
585}
586
587///
588/// IntentTotalsData
589///
590/// Canonical intent-resource-totals allocation snapshot.
591///
592
593#[derive(Clone, Debug, Default, Eq, PartialEq)]
594pub struct IntentTotalsData {
595    pub entries: Vec<IntentTotalsEntryRecord>,
596}
597
598impl IntentTotalsData {
599    pub const STATE_CONTRACT_NAME: &'static str = "IntentTotalsData";
600}
601
602///
603/// IntentPendingIndexEntryRecord
604///
605/// One logical pending-intent snapshot row preserving its stable intent ID key.
606///
607
608#[derive(Clone, Debug, Eq, PartialEq)]
609pub struct IntentPendingIndexEntryRecord {
610    pub intent_id: IntentId,
611    pub record: IntentPendingEntryRecord,
612}
613
614///
615/// IntentPendingData
616///
617/// Canonical pending-intent allocation snapshot.
618///
619
620#[derive(Clone, Debug, Default, Eq, PartialEq)]
621pub struct IntentPendingData {
622    pub entries: Vec<IntentPendingIndexEntryRecord>,
623}
624
625impl IntentPendingData {
626    pub const STATE_CONTRACT_NAME: &'static str = "IntentPendingData";
627}
628
629/// One logical finite-expiry snapshot row preserving its stable ordered key.
630#[derive(Clone, Copy, Debug, Eq, PartialEq)]
631pub struct IntentExpiryIndexEntryRecord {
632    pub key: IntentExpiryKeyRecord,
633    pub record: IntentExpiryEntryRecord,
634}
635
636/// Canonical finite-expiry index allocation snapshot.
637#[derive(Clone, Debug, Default, Eq, PartialEq)]
638pub struct IntentExpiryIndexData {
639    pub entries: Vec<IntentExpiryIndexEntryRecord>,
640}
641
642impl IntentExpiryIndexData {
643    pub const STATE_CONTRACT_NAME: &'static str = "IntentExpiryIndexData";
644}
645
646/// One logical receipt-backed intent snapshot row.
647#[derive(Clone, Debug, Eq, PartialEq)]
648pub struct ReceiptBackedIntentEntryRecord {
649    pub operation_id: OperationId,
650    pub record: ReceiptBackedIntentRecord,
651}
652
653/// Canonical receipt-backed intent record allocation snapshot.
654#[derive(Clone, Debug, Default, Eq, PartialEq)]
655pub struct ReceiptBackedIntentsData {
656    pub entries: Vec<ReceiptBackedIntentEntryRecord>,
657}
658
659impl ReceiptBackedIntentsData {
660    pub const STATE_CONTRACT_NAME: &'static str = "ReceiptBackedIntentsData";
661}
662
663/// One logical application replay metadata snapshot row.
664#[derive(Clone, Copy, Debug, Eq, PartialEq)]
665pub struct ApplicationReceiptReplayEntryRecord {
666    pub operation_id: OperationId,
667    pub record: ApplicationReceiptReplayRecord,
668}
669
670/// Canonical application receipt replay metadata allocation snapshot.
671#[derive(Clone, Debug, Default, Eq, PartialEq)]
672pub struct ApplicationReceiptReplayData {
673    pub entries: Vec<ApplicationReceiptReplayEntryRecord>,
674}
675
676/// One logical application terminal-eligibility snapshot row.
677#[derive(Clone, Copy, Debug, Eq, PartialEq)]
678pub struct ApplicationReceiptEligibilityEntryRecord {
679    pub key: ApplicationReceiptEligibilityKeyRecord,
680    pub record: ApplicationReceiptEligibilityRecord,
681}
682
683/// Canonical application terminal-eligibility allocation snapshot.
684#[derive(Clone, Debug, Default, Eq, PartialEq)]
685pub struct ApplicationReceiptEligibilityData {
686    pub entries: Vec<ApplicationReceiptEligibilityEntryRecord>,
687}
688
689impl ApplicationReceiptEligibilityData {
690    pub const STATE_CONTRACT_NAME: &'static str = "ApplicationReceiptEligibilityData";
691}
692
693impl ApplicationReceiptReplayData {
694    pub const STATE_CONTRACT_NAME: &'static str = "ApplicationReceiptReplayData";
695}
696
697/// One logical placement-acknowledgement index snapshot row.
698#[derive(Clone, Copy, Debug, Eq, PartialEq)]
699pub struct PlacementAcknowledgementIndexEntryRecord {
700    pub operation_id: OperationId,
701    pub record: PlacementAcknowledgementEntryRecord,
702}
703
704/// Canonical placement-acknowledgement derived-index allocation snapshot.
705#[derive(Clone, Debug, Default, Eq, PartialEq)]
706pub struct PlacementAcknowledgementIndexData {
707    pub entries: Vec<PlacementAcknowledgementIndexEntryRecord>,
708}
709
710impl PlacementAcknowledgementIndexData {
711    pub const STATE_CONTRACT_NAME: &'static str = "PlacementAcknowledgementIndexData";
712}
713
714///
715/// IntentStore
716///
717
718pub struct IntentStore;
719
720impl IntentStore {
721    // -------------------------------------------------------------
722    // Meta
723    // -------------------------------------------------------------
724
725    #[must_use]
726    pub(crate) fn meta() -> IntentStoreMetaRecord {
727        INTENT_META.with_borrow(|cell| *cell.get())
728    }
729
730    pub(crate) fn set_meta(meta: IntentStoreMetaRecord) {
731        INTENT_META.with_borrow_mut(|cell| cell.set(meta));
732    }
733
734    // -------------------------------------------------------------
735    // Records
736    // -------------------------------------------------------------
737
738    #[must_use]
739    pub(crate) fn get_record(id: IntentId) -> Option<IntentRecord> {
740        INTENT_RECORDS.with_borrow(|map| map.get(&id))
741    }
742
743    pub(crate) fn insert_record(record: IntentRecord) -> Option<IntentRecord> {
744        INTENT_RECORDS.with_borrow_mut(|map| map.insert(record.id, record))
745    }
746
747    // -------------------------------------------------------------
748    // Totals
749    // -------------------------------------------------------------
750
751    #[must_use]
752    pub(crate) fn get_totals(key: &IntentResourceKey) -> Option<IntentResourceTotalsRecord> {
753        INTENT_TOTALS.with_borrow(|map| map.get(key))
754    }
755
756    #[must_use]
757    pub(crate) fn totals_len() -> u64 {
758        INTENT_TOTALS.with_borrow(StableBtreeMap::len)
759    }
760
761    pub(crate) fn set_totals(
762        key: IntentResourceKey,
763        totals: IntentResourceTotalsRecord,
764    ) -> Option<IntentResourceTotalsRecord> {
765        INTENT_TOTALS.with_borrow_mut(|map| map.insert(key, totals))
766    }
767
768    pub(crate) fn remove_totals(key: &IntentResourceKey) -> Option<IntentResourceTotalsRecord> {
769        INTENT_TOTALS.with_borrow_mut(|map| map.remove(key))
770    }
771
772    // -------------------------------------------------------------
773    // Pending index
774    // -------------------------------------------------------------
775
776    #[must_use]
777    pub(crate) fn get_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
778        INTENT_PENDING.with_borrow(|map| map.get(&id))
779    }
780
781    pub(crate) fn insert_pending(
782        id: IntentId,
783        entry: IntentPendingEntryRecord,
784    ) -> Option<IntentPendingEntryRecord> {
785        INTENT_PENDING.with_borrow_mut(|map| map.insert(id, entry))
786    }
787
788    pub(crate) fn remove_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
789        INTENT_PENDING.with_borrow_mut(|map| map.remove(&id))
790    }
791
792    pub(crate) fn with_pending_entries<R>(
793        f: impl FnOnce(
794            &StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>,
795        ) -> R,
796    ) -> R {
797        INTENT_PENDING.with_borrow(|map| f(map))
798    }
799
800    // -------------------------------------------------------------
801    // Finite-expiry derived index
802    // -------------------------------------------------------------
803
804    #[must_use]
805    pub(crate) fn get_expiry(key: IntentExpiryKeyRecord) -> Option<IntentExpiryEntryRecord> {
806        INTENT_EXPIRY_INDEX.with_borrow(|map| map.get(&key))
807    }
808
809    pub(crate) fn insert_expiry(
810        key: IntentExpiryKeyRecord,
811        record: IntentExpiryEntryRecord,
812    ) -> Option<IntentExpiryEntryRecord> {
813        INTENT_EXPIRY_INDEX.with_borrow_mut(|map| map.insert(key, record))
814    }
815
816    pub(crate) fn remove_expiry(key: IntentExpiryKeyRecord) -> Option<IntentExpiryEntryRecord> {
817        INTENT_EXPIRY_INDEX.with_borrow_mut(|map| map.remove(&key))
818    }
819
820    pub(crate) fn clear_expiry_index() {
821        INTENT_EXPIRY_INDEX.with_borrow_mut(StableBtreeMap::clear_new);
822    }
823
824    pub(crate) fn with_expiry_entries<R>(
825        f: impl FnOnce(
826            &StableBtreeMap<
827                IntentExpiryKeyRecord,
828                IntentExpiryEntryRecord,
829                VirtualMemory<DefaultMemoryImpl>,
830            >,
831        ) -> R,
832    ) -> R {
833        INTENT_EXPIRY_INDEX.with_borrow(|map| f(map))
834    }
835}
836
837/// Stable store for receipt-backed operations addressed by exact operation ID.
838pub struct ReceiptBackedIntentStore;
839
840impl ReceiptBackedIntentStore {
841    #[must_use]
842    pub(crate) fn len() -> u64 {
843        RECEIPT_BACKED_INTENT_RECORDS.with_borrow(StableBtreeMap::len)
844    }
845
846    #[must_use]
847    pub(crate) fn get(operation_id: OperationId) -> Option<ReceiptBackedIntentRecord> {
848        RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| records.get(&operation_id))
849    }
850
851    pub(crate) fn insert(record: ReceiptBackedIntentRecord) -> Option<ReceiptBackedIntentRecord> {
852        RECEIPT_BACKED_INTENT_RECORDS
853            .with_borrow_mut(|records| records.insert(record.operation_id, record))
854    }
855
856    pub(crate) fn remove(operation_id: OperationId) -> Option<ReceiptBackedIntentRecord> {
857        RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(|records| records.remove(&operation_id))
858    }
859
860    pub(crate) fn with_records<R>(
861        f: impl FnOnce(
862            &StableBtreeMap<
863                OperationId,
864                ReceiptBackedIntentRecord,
865                VirtualMemory<DefaultMemoryImpl>,
866            >,
867        ) -> R,
868    ) -> R {
869        RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| f(records))
870    }
871
872    #[must_use]
873    pub(crate) fn get_application_replay(
874        operation_id: OperationId,
875    ) -> Option<ApplicationReceiptReplayRecord> {
876        APPLICATION_RECEIPT_REPLAY.with_borrow(|records| records.get(&operation_id))
877    }
878
879    pub(crate) fn insert_application_replay(
880        record: ApplicationReceiptReplayRecord,
881    ) -> Option<ApplicationReceiptReplayRecord> {
882        APPLICATION_RECEIPT_REPLAY
883            .with_borrow_mut(|records| records.insert(record.operation_id, record))
884    }
885
886    pub(crate) fn remove_application_replay(
887        operation_id: OperationId,
888    ) -> Option<ApplicationReceiptReplayRecord> {
889        APPLICATION_RECEIPT_REPLAY.with_borrow_mut(|records| records.remove(&operation_id))
890    }
891
892    pub(crate) fn with_application_replay<R>(
893        f: impl FnOnce(
894            &StableBtreeMap<
895                OperationId,
896                ApplicationReceiptReplayRecord,
897                VirtualMemory<DefaultMemoryImpl>,
898            >,
899        ) -> R,
900    ) -> R {
901        APPLICATION_RECEIPT_REPLAY.with_borrow(|records| f(records))
902    }
903
904    #[must_use]
905    pub(crate) fn application_replay_len() -> u64 {
906        APPLICATION_RECEIPT_REPLAY.with_borrow(StableBtreeMap::len)
907    }
908
909    /// Provision the pinned B-tree's maximum live-node envelope before admission.
910    pub(crate) fn reserve_application_eligibility_capacity(record_count: u64) -> bool {
911        let Some(required_pages) = application_eligibility_required_pages(record_count) else {
912            return false;
913        };
914
915        APPLICATION_RECEIPT_ELIGIBILITY.with_borrow(|state| {
916            let current_pages = state.1.size();
917            current_pages >= required_pages || state.1.grow(required_pages - current_pages) >= 0
918        })
919    }
920
921    #[must_use]
922    pub(crate) fn get_application_eligibility(
923        key: ApplicationReceiptEligibilityKeyRecord,
924    ) -> Option<ApplicationReceiptEligibilityRecord> {
925        APPLICATION_RECEIPT_ELIGIBILITY.with_borrow(|state| state.0.get(&key))
926    }
927
928    pub(crate) fn insert_application_eligibility(
929        key: ApplicationReceiptEligibilityKeyRecord,
930        record: ApplicationReceiptEligibilityRecord,
931    ) -> Option<ApplicationReceiptEligibilityRecord> {
932        APPLICATION_RECEIPT_ELIGIBILITY.with_borrow_mut(|state| state.0.insert(key, record))
933    }
934
935    pub(crate) fn remove_application_eligibility(
936        key: ApplicationReceiptEligibilityKeyRecord,
937    ) -> Option<ApplicationReceiptEligibilityRecord> {
938        APPLICATION_RECEIPT_ELIGIBILITY.with_borrow_mut(|state| state.0.remove(&key))
939    }
940
941    pub(crate) fn with_application_eligibility<R>(
942        f: impl FnOnce(
943            &StableBtreeMap<
944                ApplicationReceiptEligibilityKeyRecord,
945                ApplicationReceiptEligibilityRecord,
946                VirtualMemory<DefaultMemoryImpl>,
947            >,
948        ) -> R,
949    ) -> R {
950        APPLICATION_RECEIPT_ELIGIBILITY.with_borrow(|state| f(&state.0))
951    }
952
953    #[must_use]
954    pub(crate) fn first_application_eligibility() -> Option<(
955        ApplicationReceiptEligibilityKeyRecord,
956        ApplicationReceiptEligibilityRecord,
957    )> {
958        APPLICATION_RECEIPT_ELIGIBILITY.with_borrow(|state| {
959            state
960                .0
961                .iter()
962                .next()
963                .map(|entry| (*entry.key(), entry.value()))
964        })
965    }
966
967    #[must_use]
968    pub(crate) fn application_eligibility_reserved_pages() -> u64 {
969        APPLICATION_RECEIPT_ELIGIBILITY.with_borrow(|state| state.1.size())
970    }
971
972    #[must_use]
973    pub(crate) fn get_placement_acknowledgement(
974        operation_id: OperationId,
975    ) -> Option<PlacementAcknowledgementEntryRecord> {
976        PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow(|index| index.get(&operation_id))
977    }
978
979    pub(crate) fn insert_placement_acknowledgement(
980        record: PlacementAcknowledgementEntryRecord,
981    ) -> Option<PlacementAcknowledgementEntryRecord> {
982        PLACEMENT_ACKNOWLEDGEMENT_INDEX
983            .with_borrow_mut(|index| index.insert(record.operation_id, record))
984    }
985
986    pub(crate) fn remove_placement_acknowledgement(
987        operation_id: OperationId,
988    ) -> Option<PlacementAcknowledgementEntryRecord> {
989        PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow_mut(|index| index.remove(&operation_id))
990    }
991
992    pub(crate) fn clear_placement_acknowledgement_index() {
993        PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow_mut(StableBtreeMap::clear_new);
994    }
995
996    pub(crate) fn with_placement_acknowledgements<R>(
997        f: impl FnOnce(
998            &StableBtreeMap<
999                OperationId,
1000                PlacementAcknowledgementEntryRecord,
1001                VirtualMemory<DefaultMemoryImpl>,
1002            >,
1003        ) -> R,
1004    ) -> R {
1005        PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow(|index| f(index))
1006    }
1007}
1008
1009pub(super) fn application_eligibility_required_pages(record_count: u64) -> Option<u64> {
1010    // ic-stable-structures 0.7.2 uses minimum degree six, so every non-root
1011    // node owns at least five entries. The 2,362-byte node allocation has a
1012    // 16-byte allocator header; 116 bytes cover the map, allocator, and spare
1013    // chunk headers. The explicit admission-limit probe locks these pinned values.
1014    let maximum_nodes = record_count
1015        .checked_add(APPLICATION_RECEIPT_ELIGIBILITY_MIN_NODE_ENTRIES - 1)?
1016        / APPLICATION_RECEIPT_ELIGIBILITY_MIN_NODE_ENTRIES;
1017    let required_bytes = APPLICATION_RECEIPT_ELIGIBILITY_FIXED_BYTES
1018        .checked_add(maximum_nodes.checked_mul(APPLICATION_RECEIPT_ELIGIBILITY_CHUNK_BYTES)?)?
1019        .checked_add(WASM_PAGE_BYTES - 1)?;
1020    Some(required_bytes / WASM_PAGE_BYTES)
1021}
1022
1023//
1024// ─────────────────────────────────────────────────────────────
1025// Test helpers
1026// ─────────────────────────────────────────────────────────────
1027//
1028
1029#[cfg(test)]
1030impl IntentStore {
1031    #[must_use]
1032    pub(crate) fn export_meta() -> IntentMetaData {
1033        IntentMetaData {
1034            record: Self::meta(),
1035        }
1036    }
1037
1038    pub(crate) fn import_meta(data: IntentMetaData) {
1039        Self::set_meta(data.record);
1040    }
1041
1042    #[must_use]
1043    pub(crate) fn export_records() -> IntentRecordsData {
1044        IntentRecordsData {
1045            entries: INTENT_RECORDS.with_borrow(|map| {
1046                map.iter()
1047                    .map(|entry| IntentRecordEntryRecord {
1048                        intent_id: *entry.key(),
1049                        record: entry.value(),
1050                    })
1051                    .collect()
1052            }),
1053        }
1054    }
1055
1056    pub(crate) fn import_records(data: IntentRecordsData) {
1057        INTENT_RECORDS.with_borrow_mut(|map| {
1058            map.clear_new();
1059            for entry in data.entries {
1060                map.insert(entry.intent_id, entry.record);
1061            }
1062        });
1063    }
1064
1065    #[must_use]
1066    pub(crate) fn export_totals() -> IntentTotalsData {
1067        IntentTotalsData {
1068            entries: INTENT_TOTALS.with_borrow(|map| {
1069                map.iter()
1070                    .map(|entry| IntentTotalsEntryRecord {
1071                        resource_key: entry.key().clone(),
1072                        record: entry.value(),
1073                    })
1074                    .collect()
1075            }),
1076        }
1077    }
1078
1079    pub(crate) fn import_totals(data: IntentTotalsData) {
1080        INTENT_TOTALS.with_borrow_mut(|map| {
1081            map.clear_new();
1082            for entry in data.entries {
1083                map.insert(entry.resource_key, entry.record);
1084            }
1085        });
1086    }
1087
1088    #[must_use]
1089    pub(crate) fn export_pending() -> IntentPendingData {
1090        IntentPendingData {
1091            entries: INTENT_PENDING.with_borrow(|map| {
1092                map.iter()
1093                    .map(|entry| IntentPendingIndexEntryRecord {
1094                        intent_id: *entry.key(),
1095                        record: entry.value(),
1096                    })
1097                    .collect()
1098            }),
1099        }
1100    }
1101
1102    pub(crate) fn import_pending(data: IntentPendingData) {
1103        INTENT_PENDING.with_borrow_mut(|map| {
1104            map.clear_new();
1105            for entry in data.entries {
1106                map.insert(entry.intent_id, entry.record);
1107            }
1108        });
1109    }
1110
1111    #[must_use]
1112    pub(crate) fn export_expiry_index() -> IntentExpiryIndexData {
1113        IntentExpiryIndexData {
1114            entries: INTENT_EXPIRY_INDEX.with_borrow(|map| {
1115                map.iter()
1116                    .map(|entry| IntentExpiryIndexEntryRecord {
1117                        key: *entry.key(),
1118                        record: entry.value(),
1119                    })
1120                    .collect()
1121            }),
1122        }
1123    }
1124
1125    pub(crate) fn import_expiry_index(data: IntentExpiryIndexData) {
1126        INTENT_EXPIRY_INDEX.with_borrow_mut(|map| {
1127            map.clear_new();
1128            for entry in data.entries {
1129                map.insert(entry.key, entry.record);
1130            }
1131        });
1132    }
1133
1134    pub(crate) fn reset_for_tests() {
1135        INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
1136        INTENT_TOTALS.with_borrow_mut(StableBtreeMap::clear_new);
1137        INTENT_PENDING.with_borrow_mut(StableBtreeMap::clear_new);
1138        INTENT_EXPIRY_INDEX.with_borrow_mut(StableBtreeMap::clear_new);
1139        INTENT_META.with_borrow_mut(|cell| cell.set(IntentStoreMetaRecord::default()));
1140        ReceiptBackedIntentStore::reset_for_tests();
1141    }
1142}
1143
1144#[cfg(test)]
1145impl ReceiptBackedIntentStore {
1146    #[must_use]
1147    pub(crate) fn export_records() -> ReceiptBackedIntentsData {
1148        ReceiptBackedIntentsData {
1149            entries: RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| {
1150                records
1151                    .iter()
1152                    .map(|entry| ReceiptBackedIntentEntryRecord {
1153                        operation_id: *entry.key(),
1154                        record: entry.value(),
1155                    })
1156                    .collect()
1157            }),
1158        }
1159    }
1160
1161    pub(crate) fn import_records(data: ReceiptBackedIntentsData) {
1162        RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(|records| {
1163            records.clear_new();
1164            for entry in data.entries {
1165                records.insert(entry.operation_id, entry.record);
1166            }
1167        });
1168    }
1169
1170    #[must_use]
1171    pub(crate) fn export_application_replay() -> ApplicationReceiptReplayData {
1172        ApplicationReceiptReplayData {
1173            entries: APPLICATION_RECEIPT_REPLAY.with_borrow(|records| {
1174                records
1175                    .iter()
1176                    .map(|entry| ApplicationReceiptReplayEntryRecord {
1177                        operation_id: *entry.key(),
1178                        record: entry.value(),
1179                    })
1180                    .collect()
1181            }),
1182        }
1183    }
1184
1185    #[must_use]
1186    pub(crate) fn export_application_eligibility() -> ApplicationReceiptEligibilityData {
1187        ApplicationReceiptEligibilityData {
1188            entries: APPLICATION_RECEIPT_ELIGIBILITY.with_borrow(|state| {
1189                state
1190                    .0
1191                    .iter()
1192                    .map(|entry| ApplicationReceiptEligibilityEntryRecord {
1193                        key: *entry.key(),
1194                        record: entry.value(),
1195                    })
1196                    .collect()
1197            }),
1198        }
1199    }
1200
1201    pub(crate) fn import_application_eligibility(data: ApplicationReceiptEligibilityData) {
1202        APPLICATION_RECEIPT_ELIGIBILITY.with_borrow_mut(|state| {
1203            state.0.clear_new();
1204            for entry in data.entries {
1205                state.0.insert(entry.key, entry.record);
1206            }
1207        });
1208    }
1209
1210    pub(crate) fn import_application_replay(data: ApplicationReceiptReplayData) {
1211        APPLICATION_RECEIPT_REPLAY.with_borrow_mut(|records| {
1212            records.clear_new();
1213            for entry in data.entries {
1214                records.insert(entry.operation_id, entry.record);
1215            }
1216        });
1217    }
1218
1219    #[must_use]
1220    pub(crate) fn export_placement_acknowledgement_index() -> PlacementAcknowledgementIndexData {
1221        PlacementAcknowledgementIndexData {
1222            entries: PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow(|index| {
1223                index
1224                    .iter()
1225                    .map(|entry| PlacementAcknowledgementIndexEntryRecord {
1226                        operation_id: *entry.key(),
1227                        record: entry.value(),
1228                    })
1229                    .collect()
1230            }),
1231        }
1232    }
1233
1234    pub(crate) fn import_placement_acknowledgement_index(data: PlacementAcknowledgementIndexData) {
1235        PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow_mut(|index| {
1236            index.clear_new();
1237            for entry in data.entries {
1238                index.insert(entry.operation_id, entry.record);
1239            }
1240        });
1241    }
1242
1243    pub(crate) fn reset_for_tests() {
1244        RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
1245        APPLICATION_RECEIPT_REPLAY.with_borrow_mut(StableBtreeMap::clear_new);
1246        APPLICATION_RECEIPT_ELIGIBILITY.with_borrow_mut(|state| state.0.clear_new());
1247        PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow_mut(StableBtreeMap::clear_new);
1248    }
1249}
1250
1251#[cfg(test)]
1252mod tests {
1253    use super::*;
1254    use crate::{
1255        cdk::types::Principal,
1256        model::intent::{
1257            RECEIPT_BACKED_INTENT_SCHEMA_VERSION, TerminalEvidence, TerminalEvidenceDecision,
1258        },
1259    };
1260
1261    #[test]
1262    #[should_panic(expected = "stable IntentId is 7 bytes; expected 8")]
1263    fn malformed_stable_intent_id_fails_closed() {
1264        let _ = <IntentId as Storable>::from_bytes(Cow::Owned(vec![0; 7]));
1265    }
1266
1267    #[test]
1268    #[should_panic(expected = "stable OperationId is 31 bytes; expected 32")]
1269    fn malformed_stable_operation_id_fails_closed() {
1270        let _ = <OperationId as Storable>::from_bytes(Cow::Owned(vec![0; 31]));
1271    }
1272
1273    #[test]
1274    #[should_panic(
1275        expected = "stable ApplicationReceiptEligibilityKeyRecord is 39 bytes; expected 40"
1276    )]
1277    fn malformed_stable_application_eligibility_key_fails_closed() {
1278        let _ = ApplicationReceiptEligibilityKeyRecord::from_bytes(Cow::Owned(vec![0; 39]));
1279    }
1280
1281    #[test]
1282    fn application_eligibility_capacity_reservation_is_conservative_and_bounded() {
1283        assert_eq!(application_eligibility_required_pages(0), Some(1));
1284        assert_eq!(application_eligibility_required_pages(1), Some(1));
1285        assert_eq!(application_eligibility_required_pages(1_000), Some(8));
1286        assert_eq!(application_eligibility_required_pages(u64::MAX), None);
1287        assert!(!ReceiptBackedIntentStore::reserve_application_eligibility_capacity(u64::MAX));
1288    }
1289
1290    #[test]
1291    #[should_panic(expected = "stable IntentExpiryKeyRecord is 15 bytes; expected 16")]
1292    fn malformed_stable_intent_expiry_key_fails_closed() {
1293        let _ = <IntentExpiryKeyRecord as Storable>::from_bytes(Cow::Owned(vec![0; 15]));
1294    }
1295
1296    #[test]
1297    fn stable_intent_expiry_key_preserves_deadline_then_identity_order() {
1298        let keys = [
1299            IntentExpiryKeyRecord {
1300                due_at_secs: 11,
1301                intent_id: IntentId(1),
1302            },
1303            IntentExpiryKeyRecord {
1304                due_at_secs: 10,
1305                intent_id: IntentId(2),
1306            },
1307            IntentExpiryKeyRecord {
1308                due_at_secs: 10,
1309                intent_id: IntentId(1),
1310            },
1311        ];
1312        let mut encoded = keys.map(Storable::into_bytes);
1313        encoded.sort();
1314        assert_eq!(
1315            encoded,
1316            [
1317                keys[2].into_bytes(),
1318                keys[1].into_bytes(),
1319                keys[0].into_bytes()
1320            ]
1321        );
1322    }
1323
1324    #[test]
1325    fn intent_allocations_round_trip_through_canonical_data_snapshots() {
1326        IntentStore::reset_for_tests();
1327        let intent_id = IntentId(7);
1328        let resource_key = IntentResourceKey::new("storage:uploads");
1329        let record = IntentRecord {
1330            id: intent_id,
1331            resource_key: resource_key.clone(),
1332            quantity: 11,
1333            state: IntentState::Pending,
1334            created_at: 13,
1335            ttl_secs: Some(17),
1336        };
1337        let totals = IntentResourceTotalsRecord {
1338            reserved_qty: 11,
1339            committed_qty: 19,
1340            pending_count: 1,
1341        };
1342        let pending = IntentPendingEntryRecord {
1343            resource_key: resource_key.clone(),
1344            quantity: 11,
1345            created_at: 13,
1346            ttl_secs: Some(17),
1347        };
1348        let meta = IntentStoreMetaRecord {
1349            schema_version: INTENT_STORE_SCHEMA_VERSION,
1350            next_intent_id: IntentId(8),
1351            pending_total: 1,
1352            committed_total: 2,
1353            aborted_total: 3,
1354        };
1355
1356        IntentStore::set_meta(meta);
1357        IntentStore::insert_record(record);
1358        IntentStore::set_totals(resource_key, totals);
1359        IntentStore::insert_pending(intent_id, pending);
1360        let expiry_key = IntentExpiryKeyRecord {
1361            due_at_secs: 31,
1362            intent_id,
1363        };
1364        IntentStore::insert_expiry(expiry_key, IntentExpiryEntryRecord { intent_id });
1365
1366        let meta_data = IntentStore::export_meta();
1367        let records_data = IntentStore::export_records();
1368        let totals_data = IntentStore::export_totals();
1369        let pending_data = IntentStore::export_pending();
1370        let expiry_data = IntentStore::export_expiry_index();
1371
1372        IntentStore::reset_for_tests();
1373        IntentStore::import_meta(meta_data);
1374        IntentStore::import_records(records_data.clone());
1375        IntentStore::import_totals(totals_data.clone());
1376        IntentStore::import_pending(pending_data.clone());
1377        IntentStore::import_expiry_index(expiry_data.clone());
1378
1379        assert_eq!(IntentStore::export_meta(), meta_data);
1380        assert_eq!(IntentStore::export_records(), records_data);
1381        assert_eq!(IntentStore::export_totals(), totals_data);
1382        assert_eq!(IntentStore::export_pending(), pending_data);
1383        assert_eq!(IntentStore::export_expiry_index(), expiry_data);
1384        IntentStore::reset_for_tests();
1385    }
1386
1387    #[test]
1388    fn receipt_backed_allocations_round_trip_through_canonical_data_snapshots() {
1389        IntentStore::reset_for_tests();
1390        let application_operation_id = OperationId::from_bytes([7; 32]);
1391        let placement_operation_id = OperationId::from_bytes([8; 32]);
1392        let evidence = TerminalEvidence::new(
1393            Principal::from_slice(&[1; 29]),
1394            TerminalEvidenceDecision::Committed,
1395            [8; 32],
1396        );
1397        let record = ReceiptBackedIntentRecord {
1398            schema_version: RECEIPT_BACKED_INTENT_SCHEMA_VERSION,
1399            operation_id: application_operation_id,
1400            payload_binding: PayloadBinding::new([9; 32]),
1401            resource_key: IntentResourceKey::new("mint:collection"),
1402            quantity: 11,
1403            state: ReceiptBackedIntentState::Committed { evidence },
1404            revision: 2,
1405            created_at_ns: 13,
1406            updated_at_ns: 17,
1407        };
1408        ReceiptBackedIntentStore::insert(record);
1409        ReceiptBackedIntentStore::insert(ReceiptBackedIntentRecord {
1410            schema_version: RECEIPT_BACKED_INTENT_SCHEMA_VERSION,
1411            operation_id: placement_operation_id,
1412            payload_binding: PayloadBinding::new([10; 32]),
1413            resource_key: IntentResourceKey::new(format!("canic:placement:{}", "a".repeat(64))),
1414            quantity: 1,
1415            state: ReceiptBackedIntentState::Committed { evidence },
1416            revision: 2,
1417            created_at_ns: 13,
1418            updated_at_ns: 17,
1419        });
1420        ReceiptBackedIntentStore::insert_application_replay(ApplicationReceiptReplayRecord {
1421            schema_version: APPLICATION_RECEIPT_REPLAY_SCHEMA_VERSION,
1422            operation_id: application_operation_id,
1423            replay_deadline_ns: 23,
1424        });
1425        let eligibility_key = ApplicationReceiptEligibilityKeyRecord {
1426            eligible_at_ns: 17 + crate::model::intent::RECEIPT_TERMINAL_OBSERVATION_GRACE_NS,
1427            operation_id: application_operation_id,
1428        };
1429        ReceiptBackedIntentStore::insert_application_eligibility(
1430            eligibility_key,
1431            ApplicationReceiptEligibilityRecord {
1432                schema_version: APPLICATION_RECEIPT_ELIGIBILITY_SCHEMA_VERSION,
1433                operation_id: application_operation_id,
1434                payload_binding: PayloadBinding::new([9; 32]),
1435                terminal_revision: 2,
1436            },
1437        );
1438        ReceiptBackedIntentStore::insert_placement_acknowledgement(
1439            PlacementAcknowledgementEntryRecord {
1440                operation_id: placement_operation_id,
1441            },
1442        );
1443        let records_data = ReceiptBackedIntentStore::export_records();
1444        let replay_data = ReceiptBackedIntentStore::export_application_replay();
1445        let eligibility_data = ReceiptBackedIntentStore::export_application_eligibility();
1446        let acknowledgement_data =
1447            ReceiptBackedIntentStore::export_placement_acknowledgement_index();
1448
1449        ReceiptBackedIntentStore::reset_for_tests();
1450        assert_eq!(
1451            ReceiptBackedIntentStore::export_records(),
1452            ReceiptBackedIntentsData::default()
1453        );
1454        assert_eq!(
1455            ReceiptBackedIntentStore::export_application_replay(),
1456            ApplicationReceiptReplayData::default()
1457        );
1458        assert_eq!(
1459            ReceiptBackedIntentStore::export_application_eligibility(),
1460            ApplicationReceiptEligibilityData::default()
1461        );
1462        assert_eq!(
1463            ReceiptBackedIntentStore::export_placement_acknowledgement_index(),
1464            PlacementAcknowledgementIndexData::default()
1465        );
1466
1467        ReceiptBackedIntentStore::import_records(records_data.clone());
1468        ReceiptBackedIntentStore::import_application_replay(replay_data.clone());
1469        ReceiptBackedIntentStore::import_application_eligibility(eligibility_data.clone());
1470        ReceiptBackedIntentStore::import_placement_acknowledgement_index(
1471            acknowledgement_data.clone(),
1472        );
1473        assert_eq!(ReceiptBackedIntentStore::len(), 2);
1474        assert_eq!(ReceiptBackedIntentStore::export_records(), records_data);
1475        assert_eq!(
1476            ReceiptBackedIntentStore::export_application_replay(),
1477            replay_data
1478        );
1479        assert_eq!(
1480            ReceiptBackedIntentStore::export_application_eligibility(),
1481            eligibility_data
1482        );
1483        assert_eq!(
1484            ReceiptBackedIntentStore::export_placement_acknowledgement_index(),
1485            acknowledgement_data
1486        );
1487        IntentStore::reset_for_tests();
1488    }
1489}