Skip to main content

car_multi/
concurrency.rs

1//! Runtime concurrency-anomaly gating for multi-agent coordination
2//! (EPIC A / task A5 — arXiv 2606.17182).
3//!
4//! `car_verify::concurrency` ships the *pure* detector ([`analyze_concurrency`])
5//! and gate ([`gate_concurrency`]) over a schedule of timestamped
6//! read → generate → write [`AgentOp`]s, but nothing in the runtime ever
7//! *built* that schedule from a live multi-agent run — so the check sat dormant,
8//! a library function no coordinator called. This module closes that gap.
9//!
10//! It instruments a coordination pattern into an [`AgentOp`] schedule and runs
11//! the gate at the **cross-agent commit barrier**: the moment isolated agents
12//! merge their local write overlays back into the shared parent state. That is
13//! the only point where the paper's anomalies can occur in CAR — an isolated
14//! agent reads the parent, generates for a while, then commits, and another
15//! agent's commit can interleave.
16//!
17//! ## What the executor does with the gate's verdict
18//!
19//! [`gate_concurrency`] returns a [`Disposition`] per detected anomaly; the
20//! [`ConcurrencyControl::guard`] here maps those onto concrete commit-barrier
21//! actions, fail-closed:
22//!
23//! - **Abort** (default: `L0` causal-cascade) — the whole batch aborts; *no*
24//!   agent's writes are merged. A broken causal order can't be repaired by
25//!   dropping one writer.
26//! - **RequireApproval** (default: `L1` stale-generation) — without a human
27//!   approval sink at the library layer, the offending op's commit is
28//!   **rejected** (its writes are not merged; its output is marked errored with
29//!   the remediation reason). Siblings still commit. This is the safe reading of
30//!   a lost update: don't commit a write computed against a value another op has
31//!   already overwritten.
32//! - **AutoRemediate** (default: `L2` phantom-tool, `L3` reorder) — the batch
33//!   proceeds, but survivors are merged in a **deterministic order** (op id
34//!   ascending), which realizes the `SerializeWriters` remediation: the same
35//!   inputs always produce the same shared-state result instead of a
36//!   nondeterministic last-writer-wins race.
37//!
38//! Every gate run emits one `AdmissionGateDecision` event (`gate:"concurrency"`)
39//! to the shared event log — the audit trail that makes the check inspectable as
40//! live enforcement, mirroring the per-proposal admission gates in `car-engine`.
41
42use 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/// Opt-in runtime enforcement of concurrency-anomaly gating for a coordination
55/// group. Attach one to [`crate::SharedInfra`] to have the isolated parallel
56/// swarm gate its merge barrier; absent, coordination behaves exactly as before
57/// (purely additive, matching the empty-default-gate-list stance in
58/// `car-engine`).
59///
60/// Carries the gate [`policy`](ConcurrencyGatePolicy) and a monotonic logical
61/// clock used to stamp each op's `read_at`/`commit_at`, so the detector can tell
62/// which generate windows overlapped.
63#[derive(Clone)]
64pub struct ConcurrencyControl {
65    policy: ConcurrencyGatePolicy,
66    clock: Arc<AtomicU64>,
67}
68
69impl ConcurrencyControl {
70    /// Build with an explicit gate policy.
71    pub fn new(policy: ConcurrencyGatePolicy) -> Self {
72        Self {
73            policy,
74            clock: Arc::new(AtomicU64::new(0)),
75        }
76    }
77
78    /// Build with the default policy: abort on `L0` (causal-cascade), require
79    /// approval on `L1` (stale-generation), auto-remediate the rest.
80    pub fn with_default_policy() -> Self {
81        Self::new(ConcurrencyGatePolicy::default())
82    }
83
84    /// The gate policy in force.
85    pub fn policy(&self) -> &ConcurrencyGatePolicy {
86        &self.policy
87    }
88
89    /// Next logical timestamp on the shared monotonic clock. Agents stamp
90    /// `read_at` before they run and `commit_at` when they finish, so two agents
91    /// that ran concurrently get overlapping `[read_at, commit_at]` windows.
92    pub fn tick(&self) -> u64 {
93        self.clock.fetch_add(1, Ordering::SeqCst)
94    }
95
96    /// Analyze `ops`, gate the report under this control's policy, emit the
97    /// audit event, and return a [`ConcurrencyGuard`] telling the caller what to
98    /// do at the commit barrier. Pure aside from the single log append.
99    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/// The commit-barrier decision derived from a gated [`ConcurrencyReport`].
113#[derive(Debug, Clone)]
114pub struct ConcurrencyGuard {
115    /// The raw detector report (level + every anomaly found).
116    pub report: ConcurrencyReport,
117    /// The gate's per-anomaly remediations and dispositions.
118    pub gate: ConcurrencyGate,
119    /// True when the whole batch must abort (any `Abort` disposition). No op is
120    /// committed when set.
121    pub abort: bool,
122    /// Op ids whose individual commit must be rejected (fail-closed
123    /// `RequireApproval`, no approval sink). Their writes are not merged.
124    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    /// True when the schedule was serializable — nothing to gate.
151    pub fn is_clean(&self) -> bool {
152        self.gate.safe
153    }
154
155    /// Should the op with this id have its writes committed? False when the
156    /// whole batch aborts or this specific op was rejected for approval.
157    pub fn may_commit(&self, op_id: &str) -> bool {
158        !self.abort && !self.rejected_ops.contains(op_id)
159    }
160
161    /// A human-readable reason a specific op was held back, for the errored
162    /// output surfaced to the caller.
163    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    /// One-line summary of the anomalies found, for logs and errors.
181    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    /// The serde-stable decision label for the audit event.
194    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            // Auto-remediated only — proceeds, but not untouched.
203            "allow"
204        }
205    }
206
207    /// Emit the single `AdmissionGateDecision` audit event for this gate run.
208    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        // This gate runs at the CROSS-AGENT COMMIT BARRIER, not at
212        // pre-execution proposal admission — same event kind, different
213        // phase. Consumers assuming pre-action semantics filter on this
214        // (neo review: two emitters, two phase semantics, one event name).
215        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
248/// The op an approval-gated remediation is primarily about — the one whose
249/// commit we hold back when we can't ask a human.
250fn 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        // d depends on c but commits (1) before c (5) — L0.
294        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        // Nothing commits under an abort.
301        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        // a reads+writes k over [0,2]; b overwrites k at 1 → a is stale (L1).
310        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        // Two unordered overlapping writers to k → reorder (L3), auto-remediate.
326        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}