1use car_eventlog::{EventKind, EventLog};
43use car_verify::concurrency::{
44 analyze as analyze_concurrency, gate_concurrency, AgentOp, ConcurrencyGate,
45 ConcurrencyGatePolicy, ConcurrencyReport, Disposition, Remediation,
46};
47use serde_json::Value;
48use std::collections::HashMap;
49use std::collections::HashSet;
50use std::sync::atomic::{AtomicU64, Ordering};
51use std::sync::Arc;
52use tokio::sync::Mutex as TokioMutex;
53
54#[derive(Clone)]
64pub struct ConcurrencyControl {
65 policy: ConcurrencyGatePolicy,
66 clock: Arc<AtomicU64>,
67}
68
69impl ConcurrencyControl {
70 pub fn new(policy: ConcurrencyGatePolicy) -> Self {
72 Self {
73 policy,
74 clock: Arc::new(AtomicU64::new(0)),
75 }
76 }
77
78 pub fn with_default_policy() -> Self {
81 Self::new(ConcurrencyGatePolicy::default())
82 }
83
84 pub fn policy(&self) -> &ConcurrencyGatePolicy {
86 &self.policy
87 }
88
89 pub fn tick(&self) -> u64 {
93 self.clock.fetch_add(1, Ordering::SeqCst)
94 }
95
96 pub async fn guard(
100 &self,
101 ops: &[AgentOp],
102 log: &Arc<TokioMutex<EventLog>>,
103 ) -> ConcurrencyGuard {
104 let report = analyze_concurrency(ops);
105 let gate = gate_concurrency(&report, &self.policy);
106 let guard = ConcurrencyGuard::from_gate(report, gate);
107 guard.audit(log).await;
108 guard
109 }
110}
111
112#[derive(Debug, Clone)]
114pub struct ConcurrencyGuard {
115 pub report: ConcurrencyReport,
117 pub gate: ConcurrencyGate,
119 pub abort: bool,
122 pub rejected_ops: HashSet<String>,
125}
126
127impl ConcurrencyGuard {
128 fn from_gate(report: ConcurrencyReport, gate: ConcurrencyGate) -> Self {
129 let mut abort = false;
130 let mut rejected_ops = HashSet::new();
131 for r in &gate.remediations {
132 match r.disposition {
133 Disposition::Abort => abort = true,
134 Disposition::RequireApproval => {
135 if let Some(op) = remediation_primary_op(&r.remediation) {
136 rejected_ops.insert(op);
137 }
138 }
139 Disposition::AutoRemediate => {}
140 }
141 }
142 Self {
143 report,
144 gate,
145 abort,
146 rejected_ops,
147 }
148 }
149
150 pub fn is_clean(&self) -> bool {
152 self.gate.safe
153 }
154
155 pub fn may_commit(&self, op_id: &str) -> bool {
158 !self.abort && !self.rejected_ops.contains(op_id)
159 }
160
161 pub fn rejection_reason(&self, op_id: &str) -> Option<String> {
164 if self.abort {
165 return Some(format!(
166 "concurrency gate aborted the batch at consistency level {:?}: {}",
167 self.report.level,
168 self.anomaly_summary()
169 ));
170 }
171 if self.rejected_ops.contains(op_id) {
172 return Some(format!(
173 "concurrency gate rejected this commit (lost-update/stale generation) — {}",
174 self.anomaly_summary()
175 ));
176 }
177 None
178 }
179
180 pub fn anomaly_summary(&self) -> String {
182 if self.report.anomalies.is_empty() {
183 return "no anomalies".to_string();
184 }
185 self.report
186 .anomalies
187 .iter()
188 .map(|a| a.explanation.clone())
189 .collect::<Vec<_>>()
190 .join("; ")
191 }
192
193 fn decision_label(&self) -> &'static str {
195 if self.abort {
196 "reject"
197 } else if !self.rejected_ops.is_empty() {
198 "needs_approval"
199 } else if self.gate.remediations.is_empty() {
200 "allow"
201 } else {
202 "allow"
204 }
205 }
206
207 async fn audit(&self, log: &Arc<TokioMutex<EventLog>>) {
209 let mut data: HashMap<String, Value> = HashMap::new();
210 data.insert("gate".to_string(), Value::from("concurrency"));
211 data.insert("phase".to_string(), Value::from("commit_barrier"));
216 data.insert("decision".to_string(), Value::from(self.decision_label()));
217 data.insert(
218 "level".to_string(),
219 serde_json::to_value(self.report.level).unwrap_or(Value::Null),
220 );
221 data.insert("abort".to_string(), Value::from(self.abort));
222 if !self.report.anomalies.is_empty() {
223 data.insert("reason".to_string(), Value::from(self.anomaly_summary()));
224 data.insert(
225 "anomalies".to_string(),
226 serde_json::to_value(&self.report.anomalies).unwrap_or(Value::Null),
227 );
228 }
229 if !self.rejected_ops.is_empty() {
230 let mut blocked: Vec<String> = self.rejected_ops.iter().cloned().collect();
231 blocked.sort();
232 data.insert(
233 "blocked".to_string(),
234 serde_json::to_value(blocked).unwrap_or(Value::Null),
235 );
236 }
237 if !self.gate.remediations.is_empty() {
238 data.insert(
239 "remediations".to_string(),
240 serde_json::to_value(&self.gate.remediations).unwrap_or(Value::Null),
241 );
242 }
243 let mut log = log.lock().await;
244 log.append(EventKind::AdmissionGateDecision, None, None, data);
245 }
246}
247
248fn remediation_primary_op(r: &Remediation) -> Option<String> {
251 match r {
252 Remediation::RereadAndRegenerate { op, .. } => Some(op.clone()),
253 Remediation::PinToolRegistry { op, .. } => Some(op.clone()),
254 Remediation::EnforceCausalOrder { dependent, .. } => Some(dependent.clone()),
255 Remediation::SerializeWriters { ops, .. } => ops.first().cloned(),
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use car_eventlog::EventLog;
263
264 fn op(id: &str, read_at: u64, commit_at: u64) -> AgentOp {
265 AgentOp {
266 id: id.to_string(),
267 read_at,
268 commit_at,
269 ..Default::default()
270 }
271 }
272
273 fn log() -> Arc<TokioMutex<EventLog>> {
274 Arc::new(TokioMutex::new(EventLog::new()))
275 }
276
277 #[tokio::test]
278 async fn clean_schedule_permits_all_commits() {
279 let ctrl = ConcurrencyControl::with_default_policy();
280 let mut a = op("a", 0, 1);
281 a.write_set = vec!["x".into()];
282 let mut b = op("b", 2, 3);
283 b.write_set = vec!["y".into()];
284 let g = ctrl.guard(&[a, b], &log()).await;
285 assert!(g.is_clean());
286 assert!(!g.abort);
287 assert!(g.may_commit("a") && g.may_commit("b"));
288 }
289
290 #[tokio::test]
291 async fn causal_cascade_aborts_whole_batch() {
292 let ctrl = ConcurrencyControl::with_default_policy();
293 let mut c = op("c", 0, 5);
295 c.write_set = vec!["k".into()];
296 let mut d = op("d", 0, 1);
297 d.depends_on = vec!["c".into()];
298 let g = ctrl.guard(&[c, d], &log()).await;
299 assert!(g.abort);
300 assert!(!g.may_commit("c"));
302 assert!(!g.may_commit("d"));
303 assert!(g.rejection_reason("c").is_some());
304 }
305
306 #[tokio::test]
307 async fn stale_generation_rejects_offending_op_only() {
308 let ctrl = ConcurrencyControl::with_default_policy();
309 let mut a = op("a", 0, 2);
311 a.read_set = vec!["k".into()];
312 a.write_set = vec!["k".into()];
313 let mut b = op("b", 1, 1);
314 b.write_set = vec!["k".into()];
315 let g = ctrl.guard(&[a, b], &log()).await;
316 assert!(!g.abort, "stale generation must not abort the whole batch");
317 assert!(!g.may_commit("a"), "the stale writer is held back");
318 assert!(g.may_commit("b"), "the other writer still commits");
319 assert!(g.rejection_reason("a").is_some());
320 }
321
322 #[tokio::test]
323 async fn reorder_auto_remediates_all_commit() {
324 let ctrl = ConcurrencyControl::with_default_policy();
325 let mut a = op("a", 0, 3);
327 a.write_set = vec!["k".into()];
328 let mut b = op("b", 1, 4);
329 b.write_set = vec!["k".into()];
330 let g = ctrl.guard(&[a, b], &log()).await;
331 assert!(!g.abort);
332 assert!(g.rejected_ops.is_empty());
333 assert!(g.may_commit("a") && g.may_commit("b"));
334 assert!(!g.is_clean(), "an anomaly was still detected and audited");
335 }
336
337 #[tokio::test]
338 async fn guard_emits_admission_event() {
339 let ctrl = ConcurrencyControl::with_default_policy();
340 let l = log();
341 let mut a = op("a", 0, 3);
342 a.write_set = vec!["k".into()];
343 let mut b = op("b", 1, 4);
344 b.write_set = vec!["k".into()];
345 ctrl.guard(&[a, b], &l).await;
346 let guard = l.lock().await;
347 let events = guard.events();
348 assert_eq!(events.len(), 1);
349 assert_eq!(events[0].kind, EventKind::AdmissionGateDecision);
350 assert_eq!(
351 events[0].data.get("gate").and_then(|v| v.as_str()),
352 Some("concurrency")
353 );
354 }
355}