1use crate::ledger::{LedgerEntry, SideEffectClass};
5use serde::{Deserialize, Serialize};
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "kebab-case")]
11pub enum ReplayMode {
12 InjectCachedResult,
15 ReplayWithSameKey,
18 ReplayWithNewKey,
21 SurfaceAsFact,
24}
25
26#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct ReplayDecision {
29 pub mode: ReplayMode,
31 pub rationale: &'static str,
33}
34
35#[derive(Clone, Copy, Debug)]
46pub struct ReplayPolicy {
47 pub on_pure: ReplayMode,
48 pub on_idempotent: ReplayMode,
49 pub on_irreversible: ReplayMode,
50 pub on_network_only: ReplayMode,
51}
52
53impl Default for ReplayPolicy {
54 fn default() -> Self {
55 Self {
56 on_pure: ReplayMode::InjectCachedResult,
57 on_idempotent: ReplayMode::InjectCachedResult,
58 on_irreversible: ReplayMode::SurfaceAsFact,
59 on_network_only: ReplayMode::InjectCachedResult,
60 }
61 }
62}
63
64impl ReplayPolicy {
65 #[must_use]
68 pub const fn strict() -> Self {
69 Self {
70 on_pure: ReplayMode::InjectCachedResult,
71 on_idempotent: ReplayMode::SurfaceAsFact,
72 on_irreversible: ReplayMode::SurfaceAsFact,
73 on_network_only: ReplayMode::SurfaceAsFact,
74 }
75 }
76
77 #[must_use]
81 pub const fn aggressive() -> Self {
82 Self {
83 on_pure: ReplayMode::InjectCachedResult,
84 on_idempotent: ReplayMode::ReplayWithSameKey,
85 on_irreversible: ReplayMode::ReplayWithNewKey,
86 on_network_only: ReplayMode::ReplayWithSameKey,
87 }
88 }
89
90 #[must_use]
92 pub const fn decide(&self, entry: &LedgerEntry) -> ReplayDecision {
93 let mode = match entry.side_effect_class {
94 SideEffectClass::Pure => self.on_pure,
95 SideEffectClass::Idempotent => self.on_idempotent,
96 SideEffectClass::Irreversible => self.on_irreversible,
97 SideEffectClass::NetworkOnly => self.on_network_only,
98 };
99 let rationale = match (entry.side_effect_class, mode) {
100 (SideEffectClass::Irreversible, ReplayMode::SurfaceAsFact) => {
101 "irreversible effect: surfacing prior result as a cached fact"
102 }
103 (SideEffectClass::Irreversible, ReplayMode::ReplayWithNewKey) => {
104 "DANGER: re-issuing irreversible tool with new key (--replay-effects=all)"
105 }
106 (_, ReplayMode::InjectCachedResult) => {
107 "side-effect class is replay-safe: injecting cached result"
108 }
109 (_, ReplayMode::ReplayWithSameKey) => {
110 "re-issuing tool with same idempotency key (idempotent semantics)"
111 }
112 _ => "policy decision",
113 };
114 ReplayDecision { mode, rationale }
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use crate::ledger::SessionSecret;
122 use chrono::Utc;
123 use pf_core::digest::Digest256;
124
125 fn entry(class: SideEffectClass) -> LedgerEntry {
126 LedgerEntry {
127 timestamp: Utc::now(),
128 tool_id: "t".into(),
129 args_hash: Digest256::of(b""),
130 idempotency_key: "k".into(),
131 result_hash: Digest256::of(b""),
132 side_effect_class: class,
133 session_hmac: String::new(),
134 }
135 }
136
137 #[test]
138 fn default_never_re_issues_irreversible() {
139 let p = ReplayPolicy::default();
140 assert_eq!(
141 p.decide(&entry(SideEffectClass::Irreversible)).mode,
142 ReplayMode::SurfaceAsFact
143 );
144 }
145
146 #[test]
147 fn strict_surfaces_idempotent_too() {
148 let p = ReplayPolicy::strict();
149 assert_eq!(
150 p.decide(&entry(SideEffectClass::Idempotent)).mode,
151 ReplayMode::SurfaceAsFact
152 );
153 }
154
155 #[test]
156 fn aggressive_re_issues_irreversible() {
157 let p = ReplayPolicy::aggressive();
158 assert_eq!(
159 p.decide(&entry(SideEffectClass::Irreversible)).mode,
160 ReplayMode::ReplayWithNewKey
161 );
162 }
163
164 #[test]
165 fn touches_session_secret_only_for_construction() {
166 let _ = SessionSecret::new(b"x".to_vec());
168 }
169}