Skip to main content

canic_core/
replay_policy.rs

1//! Replay policy inventory for Canic-owned endpoint surfaces.
2//!
3//! This is Slice A scaffolding for the 0.61 replay-safety work. It records the
4//! intended replay and cost policy for endpoints emitted by Canic macros. Later
5//! slices wire these classifications into shared replay receipts and cost
6//! guards.
7
8///
9/// EndpointKind
10///
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum EndpointKind {
13    Query,
14    Update,
15}
16
17///
18/// ReplayPolicy
19///
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub enum ReplayPolicy {
22    QueryOrReadOnly,
23    ResponseIdempotent {
24        command_kind: &'static str,
25    },
26    ReplayProtected {
27        command_kind: &'static str,
28        requires_operation_id: bool,
29    },
30    MonotonicTransition {
31        command_kind: &'static str,
32    },
33    SnapshotConvergent {
34        command_kind: &'static str,
35    },
36    IntentionallyNonIdempotent {
37        command_kind: &'static str,
38        reason: &'static str,
39    },
40}
41
42///
43/// CostClass
44///
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub enum CostClass {
47    None,
48    ThresholdEcdsaSign,
49    ManagementDeployment,
50    ValueTransfer,
51    DurablePublish,
52}
53
54///
55/// ReplayImplementationStatus
56///
57#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub enum ReplayImplementationStatus {
59    Implemented,
60    ReleaseBlocker,
61}
62
63///
64/// EndpointReplayPolicy
65///
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub struct EndpointReplayPolicy {
68    pub endpoint: &'static str,
69    pub endpoint_kind: EndpointKind,
70    pub replay_policy: ReplayPolicy,
71    pub implementation_status: ReplayImplementationStatus,
72    pub cost_class: CostClass,
73    pub quota_policy: Option<&'static str>,
74    pub cycle_reserve_policy: Option<&'static str>,
75}
76
77///
78/// PoolAdminCommandReplayPolicy
79///
80#[derive(Clone, Copy, Debug, Eq, PartialEq)]
81pub struct PoolAdminCommandReplayPolicy {
82    pub variant: &'static str,
83    pub replay_policy: ReplayPolicy,
84    pub implementation_status: ReplayImplementationStatus,
85    pub cost_class: CostClass,
86    pub quota_policy: Option<&'static str>,
87    pub cycle_reserve_policy: Option<&'static str>,
88}
89
90const SIGNING_QUOTA_V1: &str = "signing.quota.v1";
91const SIGNING_RESERVE_V1: &str = "signing.cycle_reserve.v1";
92const DEPLOYMENT_QUOTA_V1: &str = "deployment.quota.v1";
93const DEPLOYMENT_RESERVE_V1: &str = "deployment.cycle_reserve.v1";
94const VALUE_TRANSFER_QUOTA_V1: &str = "value_transfer.quota.v1";
95const VALUE_TRANSFER_RESERVE_V1: &str = "value_transfer.cycle_reserve.v1";
96const DURABLE_PUBLISH_QUOTA_V1: &str = "durable_publish.quota.v1";
97const DURABLE_PUBLISH_RESERVE_V1: &str = "durable_publish.cycle_reserve.v1";
98
99pub const ENDPOINT_REPLAY_POLICY_MANIFEST: &[EndpointReplayPolicy] = &[
100    update_response_idempotent("canic_app", "app.command.v1"),
101    update_replay_blocker(
102        "canic_attestation_key_set",
103        "auth.attestation_key_set.v1",
104        CostClass::ThresholdEcdsaSign,
105        Some(SIGNING_QUOTA_V1),
106        Some(SIGNING_RESERVE_V1),
107    ),
108    update_replay_blocker(
109        "canic_canister_status",
110        "management.canister_status.v1",
111        CostClass::ManagementDeployment,
112        Some(DEPLOYMENT_QUOTA_V1),
113        Some(DEPLOYMENT_RESERVE_V1),
114    ),
115    update_replay_blocker(
116        "canic_canister_upgrade",
117        "management.canister_upgrade.v1",
118        CostClass::ManagementDeployment,
119        Some(DEPLOYMENT_QUOTA_V1),
120        Some(DEPLOYMENT_RESERVE_V1),
121    ),
122    update_replay_blocker(
123        "canic_icp_refill",
124        "icp.refill.v1",
125        CostClass::ValueTransfer,
126        Some(VALUE_TRANSFER_QUOTA_V1),
127        Some(VALUE_TRANSFER_RESERVE_V1),
128    ),
129    update_replay_blocker(
130        "canic_pool_admin",
131        "pool.admin.v1",
132        CostClass::ManagementDeployment,
133        Some(DEPLOYMENT_QUOTA_V1),
134        Some(DEPLOYMENT_RESERVE_V1),
135    ),
136    update_replay_protected(
137        "canic_request_delegation",
138        "auth.issue_delegation_proof.v1",
139        ReplayImplementationStatus::Implemented,
140        CostClass::ThresholdEcdsaSign,
141        Some(SIGNING_QUOTA_V1),
142        Some(SIGNING_RESERVE_V1),
143    ),
144    update_replay_blocker(
145        "canic_request_internal_invocation_proof",
146        "auth.issue_internal_invocation_proof.v1",
147        CostClass::ThresholdEcdsaSign,
148        Some(SIGNING_QUOTA_V1),
149        Some(SIGNING_RESERVE_V1),
150    ),
151    update_replay_blocker(
152        "canic_request_role_attestation",
153        "auth.issue_role_attestation.v1",
154        CostClass::ThresholdEcdsaSign,
155        Some(SIGNING_QUOTA_V1),
156        Some(SIGNING_RESERVE_V1),
157    ),
158    update_replay_blocker(
159        "canic_response_capability_v1",
160        "root.capability_rpc.v1",
161        CostClass::ManagementDeployment,
162        Some(DEPLOYMENT_QUOTA_V1),
163        Some(DEPLOYMENT_RESERVE_V1),
164    ),
165    update_snapshot_convergent("canic_sync_state", "cascade.sync_state.v1"),
166    update_snapshot_convergent("canic_sync_topology", "cascade.sync_topology.v1"),
167    update_monotonic_publish(
168        "canic_template_prepare_admin",
169        "wasm_store.template_prepare_admin.v1",
170    ),
171    update_monotonic_publish(
172        "canic_template_publish_chunk_admin",
173        "wasm_store.template_publish_chunk_admin.v1",
174    ),
175    update_monotonic_publish(
176        "canic_template_stage_manifest_admin",
177        "wasm_store.template_stage_manifest_admin.v1",
178    ),
179    update_response_idempotent(
180        "canic_wasm_store_bootstrap_resume_root_admin",
181        "wasm_store.bootstrap_resume.ensure_v1",
182    ),
183    update_monotonic_publish("canic_wasm_store_admin", "wasm_store.admin.v1"),
184    update_monotonic_publish("canic_wasm_store_begin_gc", "wasm_store.begin_gc.v1"),
185    update_monotonic_publish("canic_wasm_store_chunk", "wasm_store.chunk.v1"),
186    update_monotonic_publish("canic_wasm_store_complete_gc", "wasm_store.complete_gc.v1"),
187    update_monotonic_publish("canic_wasm_store_info", "wasm_store.info.v1"),
188    update_monotonic_publish("canic_wasm_store_prepare", "wasm_store.prepare.v1"),
189    update_monotonic_publish("canic_wasm_store_prepare_gc", "wasm_store.prepare_gc.v1"),
190    update_monotonic_publish(
191        "canic_wasm_store_publish_chunk",
192        "wasm_store.publish_chunk.v1",
193    ),
194    update_monotonic_publish(
195        "canic_wasm_store_stage_manifest",
196        "wasm_store.stage_manifest.v1",
197    ),
198];
199
200pub const POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST: &[PoolAdminCommandReplayPolicy] = &[
201    pool_admin_replay_protected(
202        "CreateEmpty",
203        "pool.create_empty.v1",
204        ReplayImplementationStatus::Implemented,
205        CostClass::ManagementDeployment,
206        Some(DEPLOYMENT_QUOTA_V1),
207        Some(DEPLOYMENT_RESERVE_V1),
208    ),
209    pool_admin_response_idempotent(
210        "Recycle",
211        "pool.recycle.ensure_v1",
212        ReplayImplementationStatus::ReleaseBlocker,
213        CostClass::ManagementDeployment,
214        Some(DEPLOYMENT_QUOTA_V1),
215        Some(DEPLOYMENT_RESERVE_V1),
216    ),
217    pool_admin_response_idempotent(
218        "ImportImmediate",
219        "pool.import_immediate.ensure_v1",
220        ReplayImplementationStatus::ReleaseBlocker,
221        CostClass::ManagementDeployment,
222        Some(DEPLOYMENT_QUOTA_V1),
223        Some(DEPLOYMENT_RESERVE_V1),
224    ),
225    pool_admin_response_idempotent(
226        "ImportQueued",
227        "pool.import_queued.ensure_v1",
228        ReplayImplementationStatus::ReleaseBlocker,
229        CostClass::None,
230        None,
231        None,
232    ),
233];
234
235#[must_use]
236pub const fn endpoint_replay_policy_manifest() -> &'static [EndpointReplayPolicy] {
237    ENDPOINT_REPLAY_POLICY_MANIFEST
238}
239
240#[must_use]
241pub const fn pool_admin_command_replay_policy_manifest() -> &'static [PoolAdminCommandReplayPolicy]
242{
243    POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST
244}
245
246const fn update_response_idempotent(
247    endpoint: &'static str,
248    command_kind: &'static str,
249) -> EndpointReplayPolicy {
250    EndpointReplayPolicy {
251        endpoint,
252        endpoint_kind: EndpointKind::Update,
253        replay_policy: ReplayPolicy::ResponseIdempotent { command_kind },
254        implementation_status: ReplayImplementationStatus::Implemented,
255        cost_class: CostClass::None,
256        quota_policy: None,
257        cycle_reserve_policy: None,
258    }
259}
260
261const fn update_replay_blocker(
262    endpoint: &'static str,
263    command_kind: &'static str,
264    cost_class: CostClass,
265    quota_policy: Option<&'static str>,
266    cycle_reserve_policy: Option<&'static str>,
267) -> EndpointReplayPolicy {
268    update_replay_protected(
269        endpoint,
270        command_kind,
271        ReplayImplementationStatus::ReleaseBlocker,
272        cost_class,
273        quota_policy,
274        cycle_reserve_policy,
275    )
276}
277
278const fn update_replay_protected(
279    endpoint: &'static str,
280    command_kind: &'static str,
281    implementation_status: ReplayImplementationStatus,
282    cost_class: CostClass,
283    quota_policy: Option<&'static str>,
284    cycle_reserve_policy: Option<&'static str>,
285) -> EndpointReplayPolicy {
286    EndpointReplayPolicy {
287        endpoint,
288        endpoint_kind: EndpointKind::Update,
289        replay_policy: ReplayPolicy::ReplayProtected {
290            command_kind,
291            requires_operation_id: true,
292        },
293        implementation_status,
294        cost_class,
295        quota_policy,
296        cycle_reserve_policy,
297    }
298}
299
300const fn update_monotonic_publish(
301    endpoint: &'static str,
302    command_kind: &'static str,
303) -> EndpointReplayPolicy {
304    EndpointReplayPolicy {
305        endpoint,
306        endpoint_kind: EndpointKind::Update,
307        replay_policy: ReplayPolicy::MonotonicTransition { command_kind },
308        implementation_status: ReplayImplementationStatus::Implemented,
309        cost_class: CostClass::DurablePublish,
310        quota_policy: Some(DURABLE_PUBLISH_QUOTA_V1),
311        cycle_reserve_policy: Some(DURABLE_PUBLISH_RESERVE_V1),
312    }
313}
314
315const fn update_snapshot_convergent(
316    endpoint: &'static str,
317    command_kind: &'static str,
318) -> EndpointReplayPolicy {
319    EndpointReplayPolicy {
320        endpoint,
321        endpoint_kind: EndpointKind::Update,
322        replay_policy: ReplayPolicy::SnapshotConvergent { command_kind },
323        implementation_status: ReplayImplementationStatus::Implemented,
324        cost_class: CostClass::None,
325        quota_policy: None,
326        cycle_reserve_policy: None,
327    }
328}
329
330const fn pool_admin_response_idempotent(
331    variant: &'static str,
332    command_kind: &'static str,
333    implementation_status: ReplayImplementationStatus,
334    cost_class: CostClass,
335    quota_policy: Option<&'static str>,
336    cycle_reserve_policy: Option<&'static str>,
337) -> PoolAdminCommandReplayPolicy {
338    PoolAdminCommandReplayPolicy {
339        variant,
340        replay_policy: ReplayPolicy::ResponseIdempotent { command_kind },
341        implementation_status,
342        cost_class,
343        quota_policy,
344        cycle_reserve_policy,
345    }
346}
347
348const fn pool_admin_replay_protected(
349    variant: &'static str,
350    command_kind: &'static str,
351    implementation_status: ReplayImplementationStatus,
352    cost_class: CostClass,
353    quota_policy: Option<&'static str>,
354    cycle_reserve_policy: Option<&'static str>,
355) -> PoolAdminCommandReplayPolicy {
356    PoolAdminCommandReplayPolicy {
357        variant,
358        replay_policy: ReplayPolicy::ReplayProtected {
359            command_kind,
360            requires_operation_id: true,
361        },
362        implementation_status,
363        cost_class,
364        quota_policy,
365        cycle_reserve_policy,
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use std::collections::BTreeSet;
373
374    #[test]
375    fn endpoint_manifest_entries_are_unique() {
376        let mut seen = BTreeSet::new();
377        for entry in ENDPOINT_REPLAY_POLICY_MANIFEST {
378            assert!(
379                seen.insert(entry.endpoint),
380                "duplicate replay policy entry for {}",
381                entry.endpoint
382            );
383        }
384    }
385
386    #[test]
387    fn emitted_canic_update_endpoints_have_replay_policy_entries() {
388        let emitted = emitted_update_endpoint_names();
389        let manifest = ENDPOINT_REPLAY_POLICY_MANIFEST
390            .iter()
391            .filter(|entry| entry.endpoint_kind == EndpointKind::Update)
392            .map(|entry| entry.endpoint)
393            .collect::<BTreeSet<_>>();
394
395        let missing = emitted.difference(&manifest).copied().collect::<Vec<_>>();
396
397        assert!(
398            missing.is_empty(),
399            "missing replay policy entries for update endpoints: {missing:?}"
400        );
401    }
402
403    #[test]
404    fn costed_manifest_entries_declare_guards() {
405        for entry in ENDPOINT_REPLAY_POLICY_MANIFEST {
406            if entry.cost_class == CostClass::None {
407                continue;
408            }
409            assert!(
410                entry.quota_policy.is_some(),
411                "costed entry {} missing quota policy",
412                entry.endpoint
413            );
414            assert!(
415                entry.cycle_reserve_policy.is_some(),
416                "costed entry {} missing cycle-reserve policy",
417                entry.endpoint
418            );
419        }
420    }
421
422    #[test]
423    fn costed_pool_admin_command_entries_declare_guards() {
424        for entry in POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST {
425            if entry.cost_class == CostClass::None {
426                continue;
427            }
428            assert!(
429                entry.quota_policy.is_some(),
430                "costed pool admin command {} missing quota policy",
431                entry.variant
432            );
433            assert!(
434                entry.cycle_reserve_policy.is_some(),
435                "costed pool admin command {} missing cycle-reserve policy",
436                entry.variant
437            );
438        }
439    }
440
441    #[test]
442    fn delegation_proof_issuance_is_manifested_as_implemented() {
443        let entry = ENDPOINT_REPLAY_POLICY_MANIFEST
444            .iter()
445            .find(|entry| entry.endpoint == "canic_request_delegation")
446            .expect("delegation endpoint policy entry");
447
448        assert_eq!(
449            entry.implementation_status,
450            ReplayImplementationStatus::Implemented
451        );
452        assert_eq!(entry.cost_class, CostClass::ThresholdEcdsaSign);
453        assert_eq!(
454            entry.replay_policy,
455            ReplayPolicy::ReplayProtected {
456                command_kind: "auth.issue_delegation_proof.v1",
457                requires_operation_id: true,
458            }
459        );
460    }
461
462    #[test]
463    fn pool_admin_command_variants_have_replay_policy_entries() {
464        let variants = pool_admin_command_variant_names();
465        let manifest = POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST
466            .iter()
467            .map(|entry| entry.variant)
468            .collect::<BTreeSet<_>>();
469
470        assert_eq!(manifest, variants);
471    }
472
473    #[test]
474    fn pool_create_empty_command_is_manifested_as_implemented() {
475        let entry = POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST
476            .iter()
477            .find(|entry| entry.variant == "CreateEmpty")
478            .expect("CreateEmpty command policy entry");
479
480        assert_eq!(
481            entry.implementation_status,
482            ReplayImplementationStatus::Implemented
483        );
484        assert_eq!(
485            entry.replay_policy,
486            ReplayPolicy::ReplayProtected {
487                command_kind: "pool.create_empty.v1",
488                requires_operation_id: true,
489            }
490        );
491    }
492
493    #[test]
494    fn pool_admin_non_create_variants_remain_explicit_release_blockers() {
495        for variant in ["Recycle", "ImportImmediate", "ImportQueued"] {
496            let entry = POOL_ADMIN_COMMAND_REPLAY_POLICY_MANIFEST
497                .iter()
498                .find(|entry| entry.variant == variant)
499                .expect("pool admin command policy entry");
500            assert_eq!(
501                entry.implementation_status,
502                ReplayImplementationStatus::ReleaseBlocker
503            );
504            assert!(
505                matches!(entry.replay_policy, ReplayPolicy::ResponseIdempotent { .. }),
506                "{variant} must declare its chosen replay class"
507            );
508        }
509    }
510
511    #[test]
512    fn intentionally_non_idempotent_entries_must_state_reason() {
513        for entry in ENDPOINT_REPLAY_POLICY_MANIFEST {
514            if let ReplayPolicy::IntentionallyNonIdempotent { reason, .. } = entry.replay_policy {
515                assert!(
516                    !reason.trim().is_empty(),
517                    "non-idempotent entry {} must state a reason",
518                    entry.endpoint
519                );
520            }
521        }
522    }
523
524    fn pool_admin_command_variant_names() -> BTreeSet<&'static str> {
525        let source = include_str!("dto/pool.rs");
526        let marker = "pub enum PoolAdminCommand";
527        let start = source
528            .find(marker)
529            .expect("PoolAdminCommand enum exists in pool DTO");
530        let body_start = source[start..]
531            .find('{')
532            .map(|offset| start + offset + 1)
533            .expect("PoolAdminCommand enum has body");
534        let body_end = source[body_start..]
535            .find("\n}")
536            .map(|offset| body_start + offset)
537            .expect("PoolAdminCommand enum body closes");
538
539        source[body_start..body_end]
540            .lines()
541            .filter_map(pool_admin_command_variant_name_from_line)
542            .collect()
543    }
544
545    fn pool_admin_command_variant_name_from_line(line: &'static str) -> Option<&'static str> {
546        let line = line.trim();
547        let first = line.as_bytes().first().copied()?;
548        if !first.is_ascii_uppercase() {
549            return None;
550        }
551        let end = line
552            .find(|ch: char| ch == '(' || ch == '{' || ch == ',' || ch.is_whitespace())
553            .unwrap_or(line.len());
554        Some(&line[..end])
555    }
556
557    fn emitted_update_endpoint_names() -> BTreeSet<&'static str> {
558        [
559            include_str!("../../canic/src/macros/endpoints/root.rs"),
560            include_str!("../../canic/src/macros/endpoints/shared.rs"),
561            include_str!("../../canic/src/macros/endpoints/wasm_store.rs"),
562            include_str!("../../canic/src/macros/endpoints/nonroot.rs"),
563            include_str!("../../canic/src/macros/endpoints/icp_refill.rs"),
564        ]
565        .into_iter()
566        .flat_map(update_endpoint_names_from_source)
567        .collect()
568    }
569
570    fn update_endpoint_names_from_source(source: &'static str) -> Vec<&'static str> {
571        let lines = source.lines().collect::<Vec<_>>();
572        let mut names = Vec::new();
573        for (index, line) in lines.iter().enumerate() {
574            if !line.contains("#[$crate::canic_update") {
575                continue;
576            }
577            let Some(name) = lines
578                .iter()
579                .skip(index + 1)
580                .take(6)
581                .find_map(|candidate| endpoint_name_from_fn_line(candidate))
582            else {
583                panic!("canic_update endpoint attribute without following function");
584            };
585            names.push(name);
586        }
587        names
588    }
589
590    fn endpoint_name_from_fn_line(line: &'static str) -> Option<&'static str> {
591        let marker = "fn ";
592        let start = line.find(marker)? + marker.len();
593        let rest = &line[start..];
594        let end = rest.find('(')?;
595        Some(&rest[..end])
596    }
597}