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    pub(crate) fn set_totals(
757        key: IntentResourceKey,
758        totals: IntentResourceTotalsRecord,
759    ) -> Option<IntentResourceTotalsRecord> {
760        INTENT_TOTALS.with_borrow_mut(|map| map.insert(key, totals))
761    }
762
763    pub(crate) fn remove_totals(key: &IntentResourceKey) -> Option<IntentResourceTotalsRecord> {
764        INTENT_TOTALS.with_borrow_mut(|map| map.remove(key))
765    }
766
767    // -------------------------------------------------------------
768    // Pending index
769    // -------------------------------------------------------------
770
771    #[must_use]
772    pub(crate) fn get_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
773        INTENT_PENDING.with_borrow(|map| map.get(&id))
774    }
775
776    pub(crate) fn insert_pending(
777        id: IntentId,
778        entry: IntentPendingEntryRecord,
779    ) -> Option<IntentPendingEntryRecord> {
780        INTENT_PENDING.with_borrow_mut(|map| map.insert(id, entry))
781    }
782
783    pub(crate) fn remove_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
784        INTENT_PENDING.with_borrow_mut(|map| map.remove(&id))
785    }
786
787    pub(crate) fn with_pending_entries<R>(
788        f: impl FnOnce(
789            &StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>,
790        ) -> R,
791    ) -> R {
792        INTENT_PENDING.with_borrow(|map| f(map))
793    }
794
795    // -------------------------------------------------------------
796    // Finite-expiry derived index
797    // -------------------------------------------------------------
798
799    #[must_use]
800    pub(crate) fn get_expiry(key: IntentExpiryKeyRecord) -> Option<IntentExpiryEntryRecord> {
801        INTENT_EXPIRY_INDEX.with_borrow(|map| map.get(&key))
802    }
803
804    pub(crate) fn insert_expiry(
805        key: IntentExpiryKeyRecord,
806        record: IntentExpiryEntryRecord,
807    ) -> Option<IntentExpiryEntryRecord> {
808        INTENT_EXPIRY_INDEX.with_borrow_mut(|map| map.insert(key, record))
809    }
810
811    pub(crate) fn remove_expiry(key: IntentExpiryKeyRecord) -> Option<IntentExpiryEntryRecord> {
812        INTENT_EXPIRY_INDEX.with_borrow_mut(|map| map.remove(&key))
813    }
814
815    pub(crate) fn clear_expiry_index() {
816        INTENT_EXPIRY_INDEX.with_borrow_mut(StableBtreeMap::clear_new);
817    }
818
819    pub(crate) fn with_expiry_entries<R>(
820        f: impl FnOnce(
821            &StableBtreeMap<
822                IntentExpiryKeyRecord,
823                IntentExpiryEntryRecord,
824                VirtualMemory<DefaultMemoryImpl>,
825            >,
826        ) -> R,
827    ) -> R {
828        INTENT_EXPIRY_INDEX.with_borrow(|map| f(map))
829    }
830}
831
832/// Stable store for receipt-backed operations addressed by exact operation ID.
833pub struct ReceiptBackedIntentStore;
834
835impl ReceiptBackedIntentStore {
836    #[must_use]
837    pub(crate) fn len() -> u64 {
838        RECEIPT_BACKED_INTENT_RECORDS.with_borrow(StableBtreeMap::len)
839    }
840
841    #[must_use]
842    pub(crate) fn get(operation_id: OperationId) -> Option<ReceiptBackedIntentRecord> {
843        RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| records.get(&operation_id))
844    }
845
846    pub(crate) fn insert(record: ReceiptBackedIntentRecord) -> Option<ReceiptBackedIntentRecord> {
847        RECEIPT_BACKED_INTENT_RECORDS
848            .with_borrow_mut(|records| records.insert(record.operation_id, record))
849    }
850
851    pub(crate) fn remove(operation_id: OperationId) -> Option<ReceiptBackedIntentRecord> {
852        RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(|records| records.remove(&operation_id))
853    }
854
855    pub(crate) fn with_records<R>(
856        f: impl FnOnce(
857            &StableBtreeMap<
858                OperationId,
859                ReceiptBackedIntentRecord,
860                VirtualMemory<DefaultMemoryImpl>,
861            >,
862        ) -> R,
863    ) -> R {
864        RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| f(records))
865    }
866
867    #[must_use]
868    pub(crate) fn get_application_replay(
869        operation_id: OperationId,
870    ) -> Option<ApplicationReceiptReplayRecord> {
871        APPLICATION_RECEIPT_REPLAY.with_borrow(|records| records.get(&operation_id))
872    }
873
874    pub(crate) fn insert_application_replay(
875        record: ApplicationReceiptReplayRecord,
876    ) -> Option<ApplicationReceiptReplayRecord> {
877        APPLICATION_RECEIPT_REPLAY
878            .with_borrow_mut(|records| records.insert(record.operation_id, record))
879    }
880
881    pub(crate) fn remove_application_replay(
882        operation_id: OperationId,
883    ) -> Option<ApplicationReceiptReplayRecord> {
884        APPLICATION_RECEIPT_REPLAY.with_borrow_mut(|records| records.remove(&operation_id))
885    }
886
887    pub(crate) fn with_application_replay<R>(
888        f: impl FnOnce(
889            &StableBtreeMap<
890                OperationId,
891                ApplicationReceiptReplayRecord,
892                VirtualMemory<DefaultMemoryImpl>,
893            >,
894        ) -> R,
895    ) -> R {
896        APPLICATION_RECEIPT_REPLAY.with_borrow(|records| f(records))
897    }
898
899    #[must_use]
900    pub(crate) fn application_replay_len() -> u64 {
901        APPLICATION_RECEIPT_REPLAY.with_borrow(StableBtreeMap::len)
902    }
903
904    /// Provision the pinned B-tree's maximum live-node envelope before admission.
905    pub(crate) fn reserve_application_eligibility_capacity(record_count: u64) -> bool {
906        let Some(required_pages) = application_eligibility_required_pages(record_count) else {
907            return false;
908        };
909
910        APPLICATION_RECEIPT_ELIGIBILITY.with_borrow(|state| {
911            let current_pages = state.1.size();
912            current_pages >= required_pages || state.1.grow(required_pages - current_pages) >= 0
913        })
914    }
915
916    #[must_use]
917    pub(crate) fn get_application_eligibility(
918        key: ApplicationReceiptEligibilityKeyRecord,
919    ) -> Option<ApplicationReceiptEligibilityRecord> {
920        APPLICATION_RECEIPT_ELIGIBILITY.with_borrow(|state| state.0.get(&key))
921    }
922
923    pub(crate) fn insert_application_eligibility(
924        key: ApplicationReceiptEligibilityKeyRecord,
925        record: ApplicationReceiptEligibilityRecord,
926    ) -> Option<ApplicationReceiptEligibilityRecord> {
927        APPLICATION_RECEIPT_ELIGIBILITY.with_borrow_mut(|state| state.0.insert(key, record))
928    }
929
930    pub(crate) fn remove_application_eligibility(
931        key: ApplicationReceiptEligibilityKeyRecord,
932    ) -> Option<ApplicationReceiptEligibilityRecord> {
933        APPLICATION_RECEIPT_ELIGIBILITY.with_borrow_mut(|state| state.0.remove(&key))
934    }
935
936    pub(crate) fn with_application_eligibility<R>(
937        f: impl FnOnce(
938            &StableBtreeMap<
939                ApplicationReceiptEligibilityKeyRecord,
940                ApplicationReceiptEligibilityRecord,
941                VirtualMemory<DefaultMemoryImpl>,
942            >,
943        ) -> R,
944    ) -> R {
945        APPLICATION_RECEIPT_ELIGIBILITY.with_borrow(|state| f(&state.0))
946    }
947
948    #[must_use]
949    pub(crate) fn first_application_eligibility() -> Option<(
950        ApplicationReceiptEligibilityKeyRecord,
951        ApplicationReceiptEligibilityRecord,
952    )> {
953        APPLICATION_RECEIPT_ELIGIBILITY.with_borrow(|state| {
954            state
955                .0
956                .iter()
957                .next()
958                .map(|entry| (*entry.key(), entry.value()))
959        })
960    }
961
962    #[must_use]
963    pub(crate) fn application_eligibility_reserved_pages() -> u64 {
964        APPLICATION_RECEIPT_ELIGIBILITY.with_borrow(|state| state.1.size())
965    }
966
967    #[must_use]
968    pub(crate) fn get_placement_acknowledgement(
969        operation_id: OperationId,
970    ) -> Option<PlacementAcknowledgementEntryRecord> {
971        PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow(|index| index.get(&operation_id))
972    }
973
974    pub(crate) fn insert_placement_acknowledgement(
975        record: PlacementAcknowledgementEntryRecord,
976    ) -> Option<PlacementAcknowledgementEntryRecord> {
977        PLACEMENT_ACKNOWLEDGEMENT_INDEX
978            .with_borrow_mut(|index| index.insert(record.operation_id, record))
979    }
980
981    pub(crate) fn remove_placement_acknowledgement(
982        operation_id: OperationId,
983    ) -> Option<PlacementAcknowledgementEntryRecord> {
984        PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow_mut(|index| index.remove(&operation_id))
985    }
986
987    pub(crate) fn clear_placement_acknowledgement_index() {
988        PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow_mut(StableBtreeMap::clear_new);
989    }
990
991    pub(crate) fn with_placement_acknowledgements<R>(
992        f: impl FnOnce(
993            &StableBtreeMap<
994                OperationId,
995                PlacementAcknowledgementEntryRecord,
996                VirtualMemory<DefaultMemoryImpl>,
997            >,
998        ) -> R,
999    ) -> R {
1000        PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow(|index| f(index))
1001    }
1002}
1003
1004pub(super) fn application_eligibility_required_pages(record_count: u64) -> Option<u64> {
1005    // ic-stable-structures 0.7.2 uses minimum degree six, so every non-root
1006    // node owns at least five entries. The 2,362-byte node allocation has a
1007    // 16-byte allocator header; 116 bytes cover the map, allocator, and spare
1008    // chunk headers. The explicit admission-limit probe locks these pinned values.
1009    let maximum_nodes = record_count
1010        .checked_add(APPLICATION_RECEIPT_ELIGIBILITY_MIN_NODE_ENTRIES - 1)?
1011        / APPLICATION_RECEIPT_ELIGIBILITY_MIN_NODE_ENTRIES;
1012    let required_bytes = APPLICATION_RECEIPT_ELIGIBILITY_FIXED_BYTES
1013        .checked_add(maximum_nodes.checked_mul(APPLICATION_RECEIPT_ELIGIBILITY_CHUNK_BYTES)?)?
1014        .checked_add(WASM_PAGE_BYTES - 1)?;
1015    Some(required_bytes / WASM_PAGE_BYTES)
1016}
1017
1018//
1019// ─────────────────────────────────────────────────────────────
1020// Test helpers
1021// ─────────────────────────────────────────────────────────────
1022//
1023
1024#[cfg(test)]
1025impl IntentStore {
1026    #[must_use]
1027    pub(crate) fn export_meta() -> IntentMetaData {
1028        IntentMetaData {
1029            record: Self::meta(),
1030        }
1031    }
1032
1033    pub(crate) fn import_meta(data: IntentMetaData) {
1034        Self::set_meta(data.record);
1035    }
1036
1037    #[must_use]
1038    pub(crate) fn export_records() -> IntentRecordsData {
1039        IntentRecordsData {
1040            entries: INTENT_RECORDS.with_borrow(|map| {
1041                map.iter()
1042                    .map(|entry| IntentRecordEntryRecord {
1043                        intent_id: *entry.key(),
1044                        record: entry.value(),
1045                    })
1046                    .collect()
1047            }),
1048        }
1049    }
1050
1051    pub(crate) fn import_records(data: IntentRecordsData) {
1052        INTENT_RECORDS.with_borrow_mut(|map| {
1053            map.clear_new();
1054            for entry in data.entries {
1055                map.insert(entry.intent_id, entry.record);
1056            }
1057        });
1058    }
1059
1060    #[must_use]
1061    pub(crate) fn export_totals() -> IntentTotalsData {
1062        IntentTotalsData {
1063            entries: INTENT_TOTALS.with_borrow(|map| {
1064                map.iter()
1065                    .map(|entry| IntentTotalsEntryRecord {
1066                        resource_key: entry.key().clone(),
1067                        record: entry.value(),
1068                    })
1069                    .collect()
1070            }),
1071        }
1072    }
1073
1074    pub(crate) fn import_totals(data: IntentTotalsData) {
1075        INTENT_TOTALS.with_borrow_mut(|map| {
1076            map.clear_new();
1077            for entry in data.entries {
1078                map.insert(entry.resource_key, entry.record);
1079            }
1080        });
1081    }
1082
1083    #[must_use]
1084    pub(crate) fn export_pending() -> IntentPendingData {
1085        IntentPendingData {
1086            entries: INTENT_PENDING.with_borrow(|map| {
1087                map.iter()
1088                    .map(|entry| IntentPendingIndexEntryRecord {
1089                        intent_id: *entry.key(),
1090                        record: entry.value(),
1091                    })
1092                    .collect()
1093            }),
1094        }
1095    }
1096
1097    pub(crate) fn import_pending(data: IntentPendingData) {
1098        INTENT_PENDING.with_borrow_mut(|map| {
1099            map.clear_new();
1100            for entry in data.entries {
1101                map.insert(entry.intent_id, entry.record);
1102            }
1103        });
1104    }
1105
1106    #[must_use]
1107    pub(crate) fn export_expiry_index() -> IntentExpiryIndexData {
1108        IntentExpiryIndexData {
1109            entries: INTENT_EXPIRY_INDEX.with_borrow(|map| {
1110                map.iter()
1111                    .map(|entry| IntentExpiryIndexEntryRecord {
1112                        key: *entry.key(),
1113                        record: entry.value(),
1114                    })
1115                    .collect()
1116            }),
1117        }
1118    }
1119
1120    pub(crate) fn import_expiry_index(data: IntentExpiryIndexData) {
1121        INTENT_EXPIRY_INDEX.with_borrow_mut(|map| {
1122            map.clear_new();
1123            for entry in data.entries {
1124                map.insert(entry.key, entry.record);
1125            }
1126        });
1127    }
1128
1129    pub(crate) fn reset_for_tests() {
1130        INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
1131        INTENT_TOTALS.with_borrow_mut(StableBtreeMap::clear_new);
1132        INTENT_PENDING.with_borrow_mut(StableBtreeMap::clear_new);
1133        INTENT_EXPIRY_INDEX.with_borrow_mut(StableBtreeMap::clear_new);
1134        INTENT_META.with_borrow_mut(|cell| cell.set(IntentStoreMetaRecord::default()));
1135        ReceiptBackedIntentStore::reset_for_tests();
1136    }
1137}
1138
1139#[cfg(test)]
1140impl ReceiptBackedIntentStore {
1141    #[must_use]
1142    pub(crate) fn export_records() -> ReceiptBackedIntentsData {
1143        ReceiptBackedIntentsData {
1144            entries: RECEIPT_BACKED_INTENT_RECORDS.with_borrow(|records| {
1145                records
1146                    .iter()
1147                    .map(|entry| ReceiptBackedIntentEntryRecord {
1148                        operation_id: *entry.key(),
1149                        record: entry.value(),
1150                    })
1151                    .collect()
1152            }),
1153        }
1154    }
1155
1156    pub(crate) fn import_records(data: ReceiptBackedIntentsData) {
1157        RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(|records| {
1158            records.clear_new();
1159            for entry in data.entries {
1160                records.insert(entry.operation_id, entry.record);
1161            }
1162        });
1163    }
1164
1165    #[must_use]
1166    pub(crate) fn export_application_replay() -> ApplicationReceiptReplayData {
1167        ApplicationReceiptReplayData {
1168            entries: APPLICATION_RECEIPT_REPLAY.with_borrow(|records| {
1169                records
1170                    .iter()
1171                    .map(|entry| ApplicationReceiptReplayEntryRecord {
1172                        operation_id: *entry.key(),
1173                        record: entry.value(),
1174                    })
1175                    .collect()
1176            }),
1177        }
1178    }
1179
1180    #[must_use]
1181    pub(crate) fn export_application_eligibility() -> ApplicationReceiptEligibilityData {
1182        ApplicationReceiptEligibilityData {
1183            entries: APPLICATION_RECEIPT_ELIGIBILITY.with_borrow(|state| {
1184                state
1185                    .0
1186                    .iter()
1187                    .map(|entry| ApplicationReceiptEligibilityEntryRecord {
1188                        key: *entry.key(),
1189                        record: entry.value(),
1190                    })
1191                    .collect()
1192            }),
1193        }
1194    }
1195
1196    pub(crate) fn import_application_eligibility(data: ApplicationReceiptEligibilityData) {
1197        APPLICATION_RECEIPT_ELIGIBILITY.with_borrow_mut(|state| {
1198            state.0.clear_new();
1199            for entry in data.entries {
1200                state.0.insert(entry.key, entry.record);
1201            }
1202        });
1203    }
1204
1205    pub(crate) fn import_application_replay(data: ApplicationReceiptReplayData) {
1206        APPLICATION_RECEIPT_REPLAY.with_borrow_mut(|records| {
1207            records.clear_new();
1208            for entry in data.entries {
1209                records.insert(entry.operation_id, entry.record);
1210            }
1211        });
1212    }
1213
1214    #[must_use]
1215    pub(crate) fn export_placement_acknowledgement_index() -> PlacementAcknowledgementIndexData {
1216        PlacementAcknowledgementIndexData {
1217            entries: PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow(|index| {
1218                index
1219                    .iter()
1220                    .map(|entry| PlacementAcknowledgementIndexEntryRecord {
1221                        operation_id: *entry.key(),
1222                        record: entry.value(),
1223                    })
1224                    .collect()
1225            }),
1226        }
1227    }
1228
1229    pub(crate) fn import_placement_acknowledgement_index(data: PlacementAcknowledgementIndexData) {
1230        PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow_mut(|index| {
1231            index.clear_new();
1232            for entry in data.entries {
1233                index.insert(entry.operation_id, entry.record);
1234            }
1235        });
1236    }
1237
1238    pub(crate) fn reset_for_tests() {
1239        RECEIPT_BACKED_INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
1240        APPLICATION_RECEIPT_REPLAY.with_borrow_mut(StableBtreeMap::clear_new);
1241        APPLICATION_RECEIPT_ELIGIBILITY.with_borrow_mut(|state| state.0.clear_new());
1242        PLACEMENT_ACKNOWLEDGEMENT_INDEX.with_borrow_mut(StableBtreeMap::clear_new);
1243    }
1244}
1245
1246#[cfg(test)]
1247mod tests {
1248    use super::*;
1249    use crate::{
1250        cdk::types::Principal,
1251        model::intent::{
1252            RECEIPT_BACKED_INTENT_SCHEMA_VERSION, TerminalEvidence, TerminalEvidenceDecision,
1253        },
1254    };
1255
1256    #[test]
1257    #[should_panic(expected = "stable IntentId is 7 bytes; expected 8")]
1258    fn malformed_stable_intent_id_fails_closed() {
1259        let _ = <IntentId as Storable>::from_bytes(Cow::Owned(vec![0; 7]));
1260    }
1261
1262    #[test]
1263    #[should_panic(expected = "stable OperationId is 31 bytes; expected 32")]
1264    fn malformed_stable_operation_id_fails_closed() {
1265        let _ = <OperationId as Storable>::from_bytes(Cow::Owned(vec![0; 31]));
1266    }
1267
1268    #[test]
1269    #[should_panic(
1270        expected = "stable ApplicationReceiptEligibilityKeyRecord is 39 bytes; expected 40"
1271    )]
1272    fn malformed_stable_application_eligibility_key_fails_closed() {
1273        let _ = ApplicationReceiptEligibilityKeyRecord::from_bytes(Cow::Owned(vec![0; 39]));
1274    }
1275
1276    #[test]
1277    fn application_eligibility_capacity_reservation_is_conservative_and_bounded() {
1278        assert_eq!(application_eligibility_required_pages(0), Some(1));
1279        assert_eq!(application_eligibility_required_pages(1), Some(1));
1280        assert_eq!(application_eligibility_required_pages(1_000), Some(8));
1281        assert_eq!(application_eligibility_required_pages(u64::MAX), None);
1282        assert!(!ReceiptBackedIntentStore::reserve_application_eligibility_capacity(u64::MAX));
1283    }
1284
1285    #[test]
1286    #[should_panic(expected = "stable IntentExpiryKeyRecord is 15 bytes; expected 16")]
1287    fn malformed_stable_intent_expiry_key_fails_closed() {
1288        let _ = <IntentExpiryKeyRecord as Storable>::from_bytes(Cow::Owned(vec![0; 15]));
1289    }
1290
1291    #[test]
1292    fn stable_intent_expiry_key_preserves_deadline_then_identity_order() {
1293        let keys = [
1294            IntentExpiryKeyRecord {
1295                due_at_secs: 11,
1296                intent_id: IntentId(1),
1297            },
1298            IntentExpiryKeyRecord {
1299                due_at_secs: 10,
1300                intent_id: IntentId(2),
1301            },
1302            IntentExpiryKeyRecord {
1303                due_at_secs: 10,
1304                intent_id: IntentId(1),
1305            },
1306        ];
1307        let mut encoded = keys.map(Storable::into_bytes);
1308        encoded.sort();
1309        assert_eq!(
1310            encoded,
1311            [
1312                keys[2].into_bytes(),
1313                keys[1].into_bytes(),
1314                keys[0].into_bytes()
1315            ]
1316        );
1317    }
1318
1319    #[test]
1320    fn intent_allocations_round_trip_through_canonical_data_snapshots() {
1321        IntentStore::reset_for_tests();
1322        let intent_id = IntentId(7);
1323        let resource_key = IntentResourceKey::new("storage:uploads");
1324        let record = IntentRecord {
1325            id: intent_id,
1326            resource_key: resource_key.clone(),
1327            quantity: 11,
1328            state: IntentState::Pending,
1329            created_at: 13,
1330            ttl_secs: Some(17),
1331        };
1332        let totals = IntentResourceTotalsRecord {
1333            reserved_qty: 11,
1334            committed_qty: 19,
1335            pending_count: 1,
1336        };
1337        let pending = IntentPendingEntryRecord {
1338            resource_key: resource_key.clone(),
1339            quantity: 11,
1340            created_at: 13,
1341            ttl_secs: Some(17),
1342        };
1343        let meta = IntentStoreMetaRecord {
1344            schema_version: INTENT_STORE_SCHEMA_VERSION,
1345            next_intent_id: IntentId(8),
1346            pending_total: 1,
1347            committed_total: 2,
1348            aborted_total: 3,
1349        };
1350
1351        IntentStore::set_meta(meta);
1352        IntentStore::insert_record(record);
1353        IntentStore::set_totals(resource_key, totals);
1354        IntentStore::insert_pending(intent_id, pending);
1355        let expiry_key = IntentExpiryKeyRecord {
1356            due_at_secs: 31,
1357            intent_id,
1358        };
1359        IntentStore::insert_expiry(expiry_key, IntentExpiryEntryRecord { intent_id });
1360
1361        let meta_data = IntentStore::export_meta();
1362        let records_data = IntentStore::export_records();
1363        let totals_data = IntentStore::export_totals();
1364        let pending_data = IntentStore::export_pending();
1365        let expiry_data = IntentStore::export_expiry_index();
1366
1367        IntentStore::reset_for_tests();
1368        IntentStore::import_meta(meta_data);
1369        IntentStore::import_records(records_data.clone());
1370        IntentStore::import_totals(totals_data.clone());
1371        IntentStore::import_pending(pending_data.clone());
1372        IntentStore::import_expiry_index(expiry_data.clone());
1373
1374        assert_eq!(IntentStore::export_meta(), meta_data);
1375        assert_eq!(IntentStore::export_records(), records_data);
1376        assert_eq!(IntentStore::export_totals(), totals_data);
1377        assert_eq!(IntentStore::export_pending(), pending_data);
1378        assert_eq!(IntentStore::export_expiry_index(), expiry_data);
1379        IntentStore::reset_for_tests();
1380    }
1381
1382    #[test]
1383    fn receipt_backed_allocations_round_trip_through_canonical_data_snapshots() {
1384        IntentStore::reset_for_tests();
1385        let application_operation_id = OperationId::from_bytes([7; 32]);
1386        let placement_operation_id = OperationId::from_bytes([8; 32]);
1387        let evidence = TerminalEvidence::new(
1388            Principal::from_slice(&[1; 29]),
1389            TerminalEvidenceDecision::Committed,
1390            [8; 32],
1391        );
1392        let record = ReceiptBackedIntentRecord {
1393            schema_version: RECEIPT_BACKED_INTENT_SCHEMA_VERSION,
1394            operation_id: application_operation_id,
1395            payload_binding: PayloadBinding::new([9; 32]),
1396            resource_key: IntentResourceKey::new("mint:collection"),
1397            quantity: 11,
1398            state: ReceiptBackedIntentState::Committed { evidence },
1399            revision: 2,
1400            created_at_ns: 13,
1401            updated_at_ns: 17,
1402        };
1403        ReceiptBackedIntentStore::insert(record);
1404        ReceiptBackedIntentStore::insert(ReceiptBackedIntentRecord {
1405            schema_version: RECEIPT_BACKED_INTENT_SCHEMA_VERSION,
1406            operation_id: placement_operation_id,
1407            payload_binding: PayloadBinding::new([10; 32]),
1408            resource_key: IntentResourceKey::new(format!("canic:placement:{}", "a".repeat(64))),
1409            quantity: 1,
1410            state: ReceiptBackedIntentState::Committed { evidence },
1411            revision: 2,
1412            created_at_ns: 13,
1413            updated_at_ns: 17,
1414        });
1415        ReceiptBackedIntentStore::insert_application_replay(ApplicationReceiptReplayRecord {
1416            schema_version: APPLICATION_RECEIPT_REPLAY_SCHEMA_VERSION,
1417            operation_id: application_operation_id,
1418            replay_deadline_ns: 23,
1419        });
1420        let eligibility_key = ApplicationReceiptEligibilityKeyRecord {
1421            eligible_at_ns: 17 + crate::model::intent::RECEIPT_TERMINAL_OBSERVATION_GRACE_NS,
1422            operation_id: application_operation_id,
1423        };
1424        ReceiptBackedIntentStore::insert_application_eligibility(
1425            eligibility_key,
1426            ApplicationReceiptEligibilityRecord {
1427                schema_version: APPLICATION_RECEIPT_ELIGIBILITY_SCHEMA_VERSION,
1428                operation_id: application_operation_id,
1429                payload_binding: PayloadBinding::new([9; 32]),
1430                terminal_revision: 2,
1431            },
1432        );
1433        ReceiptBackedIntentStore::insert_placement_acknowledgement(
1434            PlacementAcknowledgementEntryRecord {
1435                operation_id: placement_operation_id,
1436            },
1437        );
1438        let records_data = ReceiptBackedIntentStore::export_records();
1439        let replay_data = ReceiptBackedIntentStore::export_application_replay();
1440        let eligibility_data = ReceiptBackedIntentStore::export_application_eligibility();
1441        let acknowledgement_data =
1442            ReceiptBackedIntentStore::export_placement_acknowledgement_index();
1443
1444        ReceiptBackedIntentStore::reset_for_tests();
1445        assert_eq!(
1446            ReceiptBackedIntentStore::export_records(),
1447            ReceiptBackedIntentsData::default()
1448        );
1449        assert_eq!(
1450            ReceiptBackedIntentStore::export_application_replay(),
1451            ApplicationReceiptReplayData::default()
1452        );
1453        assert_eq!(
1454            ReceiptBackedIntentStore::export_application_eligibility(),
1455            ApplicationReceiptEligibilityData::default()
1456        );
1457        assert_eq!(
1458            ReceiptBackedIntentStore::export_placement_acknowledgement_index(),
1459            PlacementAcknowledgementIndexData::default()
1460        );
1461
1462        ReceiptBackedIntentStore::import_records(records_data.clone());
1463        ReceiptBackedIntentStore::import_application_replay(replay_data.clone());
1464        ReceiptBackedIntentStore::import_application_eligibility(eligibility_data.clone());
1465        ReceiptBackedIntentStore::import_placement_acknowledgement_index(
1466            acknowledgement_data.clone(),
1467        );
1468        assert_eq!(ReceiptBackedIntentStore::len(), 2);
1469        assert_eq!(ReceiptBackedIntentStore::export_records(), records_data);
1470        assert_eq!(
1471            ReceiptBackedIntentStore::export_application_replay(),
1472            replay_data
1473        );
1474        assert_eq!(
1475            ReceiptBackedIntentStore::export_application_eligibility(),
1476            eligibility_data
1477        );
1478        assert_eq!(
1479            ReceiptBackedIntentStore::export_placement_acknowledgement_index(),
1480            acknowledgement_data
1481        );
1482        IntentStore::reset_for_tests();
1483    }
1484}