a3s_code_core/
outcome_memory.rs1use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11const SECRET_MARKERS: &[&str] = &[
12 "sk-",
13 "api_key=",
14 "API_KEY=",
15 "AKIA",
16 "ghp_",
17 "BEGIN PRIVATE KEY",
18 "Bearer ",
19];
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum OutcomeKind {
24 Accept,
25 Revert,
26 Reject,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct OutcomeRecord {
31 pub change_digest: String,
32 pub constraint: String,
33 pub outcome: OutcomeKind,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct OutcomeLedger {
38 records: Vec<OutcomeRecord>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
42 last_promoted_digest: Option<String>,
43}
44
45impl OutcomeLedger {
46 pub fn accept(&mut self, change_digest: &str, constraint: &str) -> bool {
47 self.record(change_digest, constraint, OutcomeKind::Accept)
48 }
49
50 pub fn revert(&mut self, change_digest: &str, constraint: &str) -> bool {
51 self.record(change_digest, constraint, OutcomeKind::Revert)
52 }
53
54 pub fn reject(&mut self, change_digest: &str, constraint: &str) -> bool {
55 self.record(change_digest, constraint, OutcomeKind::Reject)
56 }
57
58 pub fn note_promoted(&mut self, digest: &str) -> bool {
60 if digest.trim().is_empty() {
61 return false;
62 }
63 self.last_promoted_digest = Some(digest.to_string());
64 true
65 }
66
67 pub fn last_promoted_digest(&self) -> Option<&str> {
68 self.last_promoted_digest.as_deref()
69 }
70
71 fn record(&mut self, change_digest: &str, constraint: &str, outcome: OutcomeKind) -> bool {
72 if change_digest.trim().is_empty() || constraint.trim().is_empty() {
73 return false;
74 }
75 if contains_secret(constraint) {
76 return false;
77 }
78 self.records.retain(|record| {
79 !(record.change_digest == change_digest && record.constraint == constraint)
80 });
81 self.records.push(OutcomeRecord {
82 change_digest: change_digest.to_string(),
83 constraint: constraint.to_string(),
84 outcome,
85 });
86 true
87 }
88
89 pub fn active_recall(&self) -> Vec<&OutcomeRecord> {
90 self.records
91 .iter()
92 .filter(|record| {
93 record.outcome == OutcomeKind::Accept && !record.change_digest.is_empty()
94 })
95 .collect()
96 }
97
98 pub fn save(&self, path: &Path) -> std::io::Result<()> {
99 if let Some(parent) = path.parent() {
100 std::fs::create_dir_all(parent)?;
101 }
102 std::fs::write(path, serde_json::to_vec(self).unwrap_or_default())
103 }
104
105 pub fn load(path: &Path) -> std::io::Result<Self> {
106 let bytes = std::fs::read(path)?;
107 serde_json::from_slice(&bytes).map_err(std::io::Error::other)
108 }
109}
110
111fn contains_secret(text: &str) -> bool {
112 SECRET_MARKERS.iter().any(|marker| text.contains(marker))
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[test]
120 fn reverted_change_is_absent_from_active_recall() {
121 let mut ledger = OutcomeLedger::default();
122 assert!(ledger.accept("digest-keep", "prefer explicit errors"));
123 assert!(ledger.revert("digest-drop", "do not unwrap"));
124 let active = ledger.active_recall();
125 assert_eq!(active.len(), 1);
126 assert_eq!(active[0].constraint, "prefer explicit errors");
127 assert!(active
128 .iter()
129 .all(|record| !record.constraint.contains("unwrap")));
130 }
131
132 #[test]
133 fn kept_change_is_active_only_with_a_digest() {
134 let mut ledger = OutcomeLedger::default();
135 assert!(!ledger.accept("", "orphan constraint"));
136 assert!(ledger.accept("digest-1", "name the missing evidence"));
137 let active = ledger.active_recall();
138 assert_eq!(active.len(), 1);
139 assert_eq!(active[0].change_digest, "digest-1");
140 }
141
142 #[test]
143 fn restart_retains_the_outcome_binding() {
144 let dir = tempfile::tempdir().unwrap();
145 let path = dir.path().join("outcomes.json");
146 let mut ledger = OutcomeLedger::default();
147 assert!(ledger.accept("digest-1", "keep the gate"));
148 assert!(ledger.revert("digest-2", "dropped idea"));
149 ledger.save(&path).unwrap();
150 let loaded = OutcomeLedger::load(&path).unwrap();
151 assert_eq!(loaded.active_recall().len(), 1);
152 assert_eq!(loaded.active_recall()[0].change_digest, "digest-1");
153 }
154
155 #[test]
156 fn promoted_digest_survives_restart_without_becoming_active() {
157 let dir = tempfile::tempdir().unwrap();
158 let path = dir.path().join("outcomes.json");
159 let mut ledger = OutcomeLedger::default();
160 assert!(ledger.note_promoted("digest-kept"));
161 ledger.save(&path).unwrap();
162 let loaded = OutcomeLedger::load(&path).unwrap();
163 assert_eq!(loaded.last_promoted_digest(), Some("digest-kept"));
164 assert!(loaded.active_recall().is_empty());
165 let digest = loaded.last_promoted_digest().unwrap().to_string();
166 let mut loaded = loaded;
167 assert!(loaded.accept(&digest, "name the missing evidence"));
168 assert_eq!(loaded.active_recall()[0].change_digest, "digest-kept");
169 }
170
171 #[test]
172 fn secret_shaped_hunk_is_not_recalled() {
173 let mut ledger = OutcomeLedger::default();
174 assert!(!ledger.accept("digest-1", "token=sk-live-secret-value"));
175 assert!(ledger.active_recall().is_empty());
176 }
177}