openlatch-client 0.5.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! Reinforcement marker parsing and bounded attempt bookkeeping.

use std::collections::HashMap;
use std::sync::Mutex;

use serde::{Deserialize, Serialize};

const MARKER: &str = "OPENLATCH-FACT:";

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ReinforcementFact {
    pub predicate: String,
    pub value: serde_json::Value,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Key {
    session_id: String,
    fingerprint: String,
}

#[derive(Debug, Clone)]
struct Exchange {
    fact: Option<ReinforcementFact>,
    attempts: u32,
    refused: bool,
}

/// Daemon-owned exchange state. Capture lag performs no write and therefore
/// cannot consume an attempt.
#[derive(Default)]
pub struct ReinforcementStore {
    exchanges: Mutex<HashMap<Key, Exchange>>,
}

impl ReinforcementStore {
    pub fn parse_marker(text: &str) -> Option<ReinforcementFact> {
        let payload = text
            .lines()
            .find_map(|line| line.trim().strip_prefix(MARKER))?;
        let value: serde_json::Value = serde_json::from_str(payload.trim()).ok()?;
        let predicate = value.get("predicate")?.as_str()?.trim();
        if predicate.is_empty() {
            return None;
        }
        Some(ReinforcementFact {
            predicate: predicate.to_string(),
            value: value
                .get("value")
                .cloned()
                .unwrap_or(serde_json::Value::Null),
        })
    }

    pub fn record_marker(
        &self,
        session_id: &str,
        fingerprint: &str,
        text: &str,
        max_attempts: u32,
    ) -> Option<ReinforcementFact> {
        let fact = Self::parse_marker(text)?;
        let mut exchanges = self.exchanges.lock().ok()?;
        let exchange = exchanges
            .entry(Key {
                session_id: session_id.to_string(),
                fingerprint: fingerprint.to_string(),
            })
            .or_insert(Exchange {
                fact: None,
                attempts: 0,
                refused: false,
            });
        if exchange.attempts >= max_attempts {
            return exchange.fact.clone();
        }
        exchange.attempts += 1;
        exchange.fact = Some(fact.clone());
        Some(fact)
    }

    pub fn record_refusal(&self, session_id: &str, fingerprint: &str, max_attempts: u32) {
        let Ok(mut exchanges) = self.exchanges.lock() else {
            return;
        };
        let exchange = exchanges
            .entry(Key {
                session_id: session_id.to_string(),
                fingerprint: fingerprint.to_string(),
            })
            .or_insert(Exchange {
                fact: None,
                attempts: 0,
                refused: false,
            });
        if exchange.attempts < max_attempts {
            exchange.attempts += 1;
        }
        exchange.refused = true;
    }

    #[cfg(test)]
    fn attempts(&self, session_id: &str, fingerprint: &str) -> u32 {
        self.exchanges
            .lock()
            .ok()
            .and_then(|m| {
                m.get(&Key {
                    session_id: session_id.into(),
                    fingerprint: fingerprint.into(),
                })
                .map(|e| e.attempts)
            })
            .unwrap_or(0)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn only_a_parsed_fact_or_refusal_consumes_an_attempt() {
        let store = ReinforcementStore::default();
        assert!(store.record_marker("s", "f", "one turn late", 2).is_none());
        assert_eq!(store.attempts("s", "f"), 0);
        let fact = store
            .record_marker(
                "s",
                "f",
                "OPENLATCH-FACT: {\"predicate\":\"approved_domains\",\"value\":[\"example.com\"]}",
                2,
            )
            .expect("marker");
        assert_eq!(fact.predicate, "approved_domains");
        assert_eq!(store.attempts("s", "f"), 1);
        store.record_refusal("s", "f", 2);
        store.record_refusal("s", "f", 2);
        assert_eq!(store.attempts("s", "f"), 2);
    }
}