Skip to main content

pf_effects/
policy.rs

1// SPDX-License-Identifier: MIT
2//! Replay-or-fork policy enforcement.
3
4use crate::ledger::{LedgerEntry, SideEffectClass};
5use serde::{Deserialize, Serialize};
6
7/// Per-class policy for what to do with a previously-recorded effect on
8/// restore.
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "kebab-case")]
11pub enum ReplayMode {
12    /// Inject the cached `result_hash` payload as if the call just returned.
13    /// Safe for `Pure`, `Idempotent`, and `NetworkOnly` by default.
14    InjectCachedResult,
15    /// Re-issue the underlying tool with the same `idempotency_key`. Safe
16    /// only for tools the author explicitly classified `Idempotent`.
17    ReplayWithSameKey,
18    /// Mint a new `idempotency_key` and re-issue. **Dangerous** — a new email
19    /// will be sent / a new charge made. Only when operator opts in.
20    ReplayWithNewKey,
21    /// Surface the previous effect as an immutable fact; do nothing on this
22    /// restore. The agent's prompt sees the result but the tool isn't called.
23    SurfaceAsFact,
24}
25
26/// The aggregate decision for one ledger entry under a [`ReplayPolicy`].
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct ReplayDecision {
29    /// Which mode to use.
30    pub mode: ReplayMode,
31    /// Human-readable rationale; surfaced in `pf checkout`'s output.
32    pub rationale: &'static str,
33}
34
35/// User-supplied policy mapping side-effect classes → replay modes.
36///
37/// Defaults match `agent_docs/effects-layer.md`:
38///
39/// | class          | default mode             |
40/// |----------------|--------------------------|
41/// | `Pure`         | `InjectCachedResult`     |
42/// | `Idempotent`   | `InjectCachedResult`     |
43/// | `Irreversible` | `SurfaceAsFact`          |
44/// | `NetworkOnly`  | `InjectCachedResult`     |
45#[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    /// Strict: `SurfaceAsFact` for everything except `Pure`. Use when restoring
66    /// snapshots from untrusted sources.
67    #[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    /// Aggressive: re-issue idempotent tools with same key, re-issue
78    /// irreversible tools with NEW keys (charges card again, etc.). Only
79    /// for explicit operator opt-in via `pf merge --replay-effects=all`.
80    #[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    /// Decide what to do with a single entry.
91    #[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        // Sanity that SessionSecret is construct-only; policy doesn't need it.
167        let _ = SessionSecret::new(b"x".to_vec());
168    }
169}